diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index ec755cd0c..6714d1357 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v4.1.7 + placeholder: v4.1.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index 3ae3cbd33..72836017b 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -39,7 +39,7 @@ body: attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v4.1.7 + placeholder: v4.1.8 validations: required: true - type: dropdown diff --git a/.github/workflows/update-translation-strings.yml b/.github/workflows/update-translation-strings.yml index bcd68c887..e78cd4296 100644 --- a/.github/workflows/update-translation-strings.yml +++ b/.github/workflows/update-translation-strings.yml @@ -18,8 +18,17 @@ jobs: NETBOX_CONFIGURATION: netbox.configuration_testing steps: + - name: Create app token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: 1076524 + private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }} + - name: Check out repo uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} - name: Set up Python uses: actions/setup-python@v5 diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md index 570563431..fc96bfd76 100644 --- a/docs/development/application-registry.md +++ b/docs/development/application-registry.md @@ -49,6 +49,10 @@ This key lists all models which have been registered in NetBox which are not des This store maintains all registered items for plugins, such as navigation menus, template extensions, etc. +### `request_processors` + +A list of context managers to invoke when processing a request e.g. in middleware or when executing a background job. Request processors can be registered with the `@register_request_processor` decorator. + ### `search` A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it. diff --git a/docs/release-notes/version-4.1.md b/docs/release-notes/version-4.1.md index 397741171..06ff12fef 100644 --- a/docs/release-notes/version-4.1.md +++ b/docs/release-notes/version-4.1.md @@ -1,6 +1,32 @@ # NetBox v4.1 -## v4.1.7 (FUTURE) +## v4.1.8 (2024-12-12) + +### Enhancements + +* [#17071](https://github.com/netbox-community/netbox/issues/17071) - Enable OOB IP address designation during bulk import +* [#17465](https://github.com/netbox-community/netbox/issues/17465) - Enable designation of rack type during bulk import & bulk edit +* [#17889](https://github.com/netbox-community/netbox/issues/17889) - Enable designating an IP address as out-of-band for a device upon creation +* [#17960](https://github.com/netbox-community/netbox/issues/17960) - Add L2TP, PPTP, Wireguard, and OpenVPN tunnel types +* [#18021](https://github.com/netbox-community/netbox/issues/18021) - Automatically clear cache on restart when `DEBUG` is enabled +* [#18061](https://github.com/netbox-community/netbox/issues/18061) - Omit stack trace from rendered device/VM configuration when an exception is raised +* [#18065](https://github.com/netbox-community/netbox/issues/18065) - Include status in device details when hovering on rack elevation +* [#18211](https://github.com/netbox-community/netbox/issues/18211) - Enable the dynamic registration of context managers for request processing + +### Bug Fixes + +* [#14044](https://github.com/netbox-community/netbox/issues/14044) - Fix unhandled AttributeError exception when bulk renaming objects +* [#17490](https://github.com/netbox-community/netbox/issues/17490) - Fix dynamic inclusion support for config templates +* [#17810](https://github.com/netbox-community/netbox/issues/17810) - Fix validation of racked device fields when modifying via REST API +* [#17820](https://github.com/netbox-community/netbox/issues/17820) - Ensure default custom field values are populated when creating new modules +* [#18044](https://github.com/netbox-community/netbox/issues/18044) - Show plugin-generated alerts within UI views for custom scripts +* [#18150](https://github.com/netbox-community/netbox/issues/18150) - Fix REST API pagination for low `MAX_PAGE_SIZE` values +* [#18183](https://github.com/netbox-community/netbox/issues/18183) - Omit UI navigation bar when printing +* [#18213](https://github.com/netbox-community/netbox/issues/18213) - Fix searching for ASN ranges by name + +--- + +## v4.1.7 (2024-11-21) ### Enhancements diff --git a/netbox/core/apps.py b/netbox/core/apps.py index 1dfc7a65e..0811e5cb2 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -1,4 +1,6 @@ from django.apps import AppConfig +from django.conf import settings +from django.core.cache import cache from django.db import models from django.db.migrations.operations import AlterModelOptions @@ -22,3 +24,7 @@ class CoreConfig(AppConfig): # Register models register_models(*self.get_models()) + + # Clear Redis cache on startup in development mode + if settings.DEBUG: + cache.clear() diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index dccca1bdf..da5a45f15 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -362,6 +362,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): queryset=RackRole.objects.all(), required=False ) + rack_type = DynamicModelChoiceField( + label=_('Rack type'), + queryset=RackType.objects.all(), + required=False, + ) serial = forms.CharField( max_length=50, required=False, @@ -441,7 +446,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): model = Rack fieldsets = ( - FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')), + FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')), FieldSet('region', 'site_group', 'site', 'location', name=_('Location')), FieldSet( 'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit', diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index a2352d806..92f7220da 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -258,6 +258,13 @@ class RackImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Name of assigned role') ) + rack_type = CSVModelChoiceField( + label=_('Rack type'), + queryset=RackType.objects.all(), + to_field_name='model', + required=False, + help_text=_('Rack type model') + ) form_factor = CSVChoiceField( label=_('Type'), choices=RackFormFactorChoices, @@ -267,8 +274,13 @@ class RackImportForm(NetBoxModelImportForm): width = forms.ChoiceField( label=_('Width'), choices=RackWidthChoices, + required=False, help_text=_('Rail-to-rail width (in inches)') ) + u_height = forms.IntegerField( + required=False, + label=_('Height (U)') + ) outer_unit = CSVChoiceField( label=_('Outer unit'), choices=RackDimensionUnitChoices, @@ -291,9 +303,9 @@ class RackImportForm(NetBoxModelImportForm): class Meta: model = Rack fields = ( - 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag', - 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', - 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', + 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial', + 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) def __init__(self, data=None, *args, **kwargs): @@ -305,6 +317,16 @@ class RackImportForm(NetBoxModelImportForm): params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) + def clean(self): + super().clean() + + # width & u_height must be set if not specifying a rack type on import + if not self.instance.pk: + if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('width'): + raise forms.ValidationError(_("Width must be set if not specifying a rack type.")) + if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('u_height'): + raise forms.ValidationError(_("U height must be set if not specifying a rack type.")) + class RackReservationImportForm(NetBoxModelImportForm): site = CSVModelChoiceField( diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 1fbffa54b..dbcd91ea0 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1277,6 +1277,11 @@ class Module(PrimaryModel, ConfigContextModel): if not disable_replication: create_instances.append(template_instance) + # Set default values for any applicable custom fields + if cf_defaults := CustomField.objects.get_defaults_for_model(component_model): + for component in create_instances: + component.custom_field_data = cf_defaults + if component_model is not ModuleBay: component_model.objects.bulk_create(create_instances) # Emit the post_save signal for each newly created object diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 81f8ad3a5..94dbeeac2 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -48,6 +48,7 @@ def get_device_description(device): Name: Role: + Status: Device Type: () Asset tag: (if defined) Serial: (if defined) @@ -55,6 +56,7 @@ def get_device_description(device): """ description = f'Name: {device.name}' description += f'\nRole: {device.role}' + description += f'\nStatus: {device.get_status_display()}' u_height = f'{floatformat(device.device_type.u_height)}U' description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})' if device.asset_tag: diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8b0628de5..9a96b0c7f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,3 @@ -import traceback - from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger @@ -2238,7 +2236,8 @@ class DeviceRenderConfigView(generic.ObjectView): # If a direct export has been requested, return the rendered template content as a # downloadable file. if request.GET.get('export'): - response = HttpResponse(context['rendered_config'], content_type='text') + content = context['rendered_config'] or context['error_message'] + response = HttpResponse(content, content_type='text') filename = f"{instance.name or 'config'}.txt" response['Content-Disposition'] = f'attachment; filename="{filename}"' return response @@ -2256,17 +2255,18 @@ class DeviceRenderConfigView(generic.ObjectView): # Render the config template rendered_config = None + error_message = None if config_template := instance.get_config_template(): try: rendered_config = config_template.render(context=context_data) except TemplateError as e: - messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e)) - rendered_config = traceback.format_exc() + error_message = _("An error occurred while rendering the template: {error}").format(error=e) return { 'config_template': config_template, 'context_data': context_data, 'rendered_config': rendered_config, + 'error_message': error_message, } diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2c390a78c..9cb9dd54a 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1210,12 +1210,14 @@ class ScriptView(BaseScriptView): script_class = self._get_script_class(script) if not script_class: return render(request, 'extras/script.html', { + 'object': script, 'script': script, }) form = script_class.as_form(initial=normalize_querydict(request.GET)) return render(request, 'extras/script.html', { + 'object': script, 'script': script, 'script_class': script_class, 'form': form, @@ -1231,6 +1233,7 @@ class ScriptView(BaseScriptView): script_class = self._get_script_class(script) if not script_class: return render(request, 'extras/script.html', { + 'object': script, 'script': script, }) @@ -1255,6 +1258,7 @@ class ScriptView(BaseScriptView): return redirect('extras:script_result', job_pk=job.pk) return render(request, 'extras/script.html', { + 'object': script, 'script': script, 'script_class': script.python_class(), 'form': form, diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index c762c15fe..18c3973e2 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -214,8 +214,10 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): def search(self, queryset, name, value): if not value.strip(): return queryset - qs_filter = Q(description__icontains=value) - return queryset.filter(qs_filter) + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 0b37665d5..c1f2dedd7 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -325,12 +325,17 @@ class IPAddressImportForm(NetBoxModelImportForm): help_text=_('Make this the primary IP for the assigned device'), required=False ) + is_oob = forms.BooleanField( + label=_('Is out-of-band'), + help_text=_('Designate this as the out-of-band IP address for the assigned device'), + required=False + ) class Meta: model = IPAddress fields = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', - 'dns_name', 'description', 'comments', 'tags', + 'is_oob', 'dns_name', 'description', 'comments', 'tags', ] def __init__(self, data=None, *args, **kwargs): @@ -344,7 +349,7 @@ class IPAddressImportForm(NetBoxModelImportForm): **{f"device__{self.fields['device'].to_field_name}": data['device']} ) - # Limit interface queryset by assigned device + # Limit interface queryset by assigned VM elif data.get('virtual_machine'): self.fields['interface'].queryset = VMInterface.objects.filter( **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} @@ -357,16 +362,29 @@ class IPAddressImportForm(NetBoxModelImportForm): virtual_machine = self.cleaned_data.get('virtual_machine') interface = self.cleaned_data.get('interface') is_primary = self.cleaned_data.get('is_primary') + is_oob = self.cleaned_data.get('is_oob') - # Validate is_primary + # Validate is_primary and is_oob if is_primary and not device and not virtual_machine: raise forms.ValidationError({ "is_primary": _("No device or virtual machine specified; cannot set as primary IP") }) + if is_oob and not device: + raise forms.ValidationError({ + "is_oob": _("No device specified; cannot set as out-of-band IP") + }) + if is_oob and virtual_machine: + raise forms.ValidationError({ + "is_oob": _("Cannot set out-of-band IP for virtual machines") + }) if is_primary and not interface: raise forms.ValidationError({ "is_primary": _("No interface specified; cannot set as primary IP") }) + if is_oob and not interface: + raise forms.ValidationError({ + "is_oob": _("No interface specified; cannot set as out-of-band IP") + }) def save(self, *args, **kwargs): @@ -385,6 +403,12 @@ class IPAddressImportForm(NetBoxModelImportForm): parent.primary_ip6 = ipaddress parent.save() + # Set as OOB for device + if self.cleaned_data.get('is_oob'): + parent = self.cleaned_data.get('device') + parent.oob_ip = ipaddress + parent.save() + return ipaddress diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 094da3007..8ad420800 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -311,6 +311,10 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): required=False, label=_('Make this the primary IP for the device/VM') ) + oob_for_parent = forms.BooleanField( + required=False, + label=_('Make this the out-of-band IP for the device') + ) comments = CommentField() fieldsets = ( @@ -322,7 +326,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): FieldSet('vminterface', name=_('Virtual Machine')), FieldSet('fhrpgroup', name=_('FHRP Group')), ), - 'primary_for_parent', name=_('Assignment') + 'primary_for_parent', 'oob_for_parent', name=_('Assignment') ), FieldSet('nat_inside', name=_('NAT IP (Inside)')), ) @@ -330,8 +334,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group', - 'tenant', 'description', 'comments', 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside', + 'tenant_group', 'tenant', 'description', 'comments', 'tags', ] def __init__(self, *args, **kwargs): @@ -350,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): super().__init__(*args, **kwargs) - # Initialize primary_for_parent if IP address is already assigned + # Initialize parent object & fields if IP address is already assigned if self.instance.pk and self.instance.assigned_object: parent = getattr(self.instance.assigned_object, 'parent_object', None) if parent and ( @@ -359,6 +363,9 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): ): self.initial['primary_for_parent'] = True + if parent and (parent.oob_ip_id == self.instance.pk): + self.initial['oob_for_parent'] = True + if type(instance.assigned_object) is Interface: self.fields['interface'].widget.add_query_params({ 'device_id': instance.assigned_object.device.pk, @@ -387,15 +394,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): }) elif selected_objects: assigned_object = self.cleaned_data[selected_objects[0]] - if ( - self.instance.pk and - self.instance.assigned_object and - self.cleaned_data['primary_for_parent'] and - assigned_object != self.instance.assigned_object - ): - raise ValidationError( - _("Cannot reassign IP address while it is designated as the primary IP for the parent object") - ) + if self.instance.pk and self.instance.assigned_object and assigned_object != self.instance.assigned_object: + if self.cleaned_data['primary_for_parent']: + raise ValidationError( + _("Cannot reassign primary IP address for the parent device/VM") + ) + if self.cleaned_data['oob_for_parent']: + raise ValidationError( + _("Cannot reassign out-of-Band IP address for the parent device") + ) self.instance.assigned_object = assigned_object else: self.instance.assigned_object = None @@ -407,6 +414,16 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): 'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.") ) + # OOB IP assignment is only available if device interface has been assigned. + interface = self.cleaned_data.get('interface') + if self.cleaned_data.get('oob_for_parent') and not interface: + self.add_error( + 'oob_for_parent', _( + "Only IP addresses assigned to a device interface can be designated as the out-of-band IP for a " + "device." + ) + ) + def save(self, *args, **kwargs): ipaddress = super().save(*args, **kwargs) @@ -428,6 +445,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): parent.primary_ip6 = None parent.save() + # Assign/clear this IPAddress as the OOB for the associated Device + if type(interface) is Interface: + parent = interface.parent_object + parent.snapshot() + if self.cleaned_data['oob_for_parent']: + parent.oob_ip = ipaddress + parent.save() + elif parent.oob_ip == ipaddress: + parent.oob_ip = None + parent.save() + return ipaddress diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index f47434ebd..f1430a9fd 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -38,12 +38,14 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def get_limit(self, request): if self.limit_query_param: + MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE + if MAX_PAGE_SIZE: + MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit) try: limit = int(request.query_params[self.limit_query_param]) if limit < 0: raise ValueError() # Enforce maximum page size, if defined - MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE if MAX_PAGE_SIZE: return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE) return limit diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index 8115fe020..6cd4e5738 100644 --- a/netbox/netbox/api/serializers/base.py +++ b/netbox/netbox/api/serializers/base.py @@ -76,6 +76,12 @@ class ValidatedModelSerializer(BaseModelSerializer): Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144) """ + + # Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead + # on our own custom model validation (below). + def get_unique_together_constraints(self, model): + return [] + def validate(self, data): # Skip validation if we're being used to represent a nested object diff --git a/netbox/netbox/context_managers.py b/netbox/netbox/context_managers.py index ca434df82..7b01cce94 100644 --- a/netbox/netbox/context_managers.py +++ b/netbox/netbox/context_managers.py @@ -1,9 +1,11 @@ from contextlib import contextmanager from netbox.context import current_request, events_queue +from netbox.utils import register_request_processor from extras.events import flush_events +@register_request_processor @contextmanager def event_tracking(request): """ diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 8012965a4..b9424bd7c 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -1,3 +1,5 @@ +from contextlib import ExitStack + import logging import uuid @@ -10,7 +12,7 @@ from django.db.utils import InternalError from django.http import Http404, HttpResponseRedirect from netbox.config import clear_config, get_config -from netbox.context_managers import event_tracking +from netbox.registry import registry from netbox.views import handler_500 from utilities.api import is_api_request from utilities.error_handlers import handle_rest_api_exception @@ -32,8 +34,10 @@ class CoreMiddleware: # Assign a random unique ID to the request. This will be used for change logging. request.id = uuid.uuid4() - # Enable the event_tracking context manager and process the request. - with event_tracking(request): + # Apply all registered request processors + with ExitStack() as stack: + for request_processor in registry['request_processors']: + stack.enter_context(request_processor(request)) response = self.get_response(request) # Check if language cookie should be renewed diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 48d7921f2..02b741779 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -29,6 +29,7 @@ registry = Registry({ 'model_features': dict(), 'models': collections.defaultdict(set), 'plugins': dict(), + 'request_processors': list(), 'search': dict(), 'system_jobs': dict(), 'tables': collections.defaultdict(dict), diff --git a/netbox/netbox/utils.py b/netbox/netbox/utils.py index f27d1b5f7..f2c34722c 100644 --- a/netbox/netbox/utils.py +++ b/netbox/netbox/utils.py @@ -3,6 +3,7 @@ from netbox.registry import registry __all__ = ( 'get_data_backend_choices', 'register_data_backend', + 'register_request_processor', ) @@ -24,3 +25,12 @@ def register_data_backend(): return cls return _wrapper + + +def register_request_processor(func): + """ + Decorator for registering a request processor. + """ + registry['request_processors'].append(func) + + return func diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 8e5fbb884..88857ad54 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -738,7 +738,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): renamed_pks = [] for obj in selected_objects: - # Take a snapshot of change-logged models if hasattr(obj, 'snapshot'): obj.snapshot() @@ -752,7 +751,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): except re.error: obj.new_name = obj.name else: - obj.new_name = obj.name.replace(find, replace) + obj.new_name = (obj.name or '').replace(find, replace) renamed_pks.append(obj.pk) return renamed_pks @@ -787,6 +786,10 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): ) return redirect(self.get_return_url(request)) + except IntegrityError as e: + messages.error(self.request, ", ".join(e.args)) + clear_events.send(sender=self) + except (AbortRequest, PermissionsViolation) as e: logger.debug(e.message) form.add_error(None, e.message) diff --git a/netbox/project-static/dist/netbox-external.css b/netbox/project-static/dist/netbox-external.css index 72d40bc92..702520216 100644 Binary files a/netbox/project-static/dist/netbox-external.css and b/netbox/project-static/dist/netbox-external.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index beee9c578..1492913d9 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 c30d6950f..bfe749fd7 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/package.json b/netbox/project-static/package.json index bbc9c6ff7..34974fe12 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -1,6 +1,6 @@ { "name": "netbox", - "version": "4.0.0", + "version": "4.1.0", "main": "dist/netbox.js", "license": "Apache-2.0", "private": true, @@ -27,10 +27,10 @@ "bootstrap": "5.3.3", "clipboard": "2.0.11", "flatpickr": "4.6.13", - "gridstack": "11.1.1", + "gridstack": "11.1.2", "htmx.org": "1.9.12", "query-string": "9.1.1", - "sass": "1.81.0", + "sass": "1.82.0", "tom-select": "2.4.1", "typeface-inter": "3.18.1", "typeface-roboto-mono": "1.1.13" diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index aa7bcdeab..a57d6dc9b 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -1904,10 +1904,10 @@ graphql@16.9.0: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f" integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw== -gridstack@11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.1.1.tgz#50f6c7a46f703a5c92a9819a607b22a6e8bd9703" - integrity sha512-St50Ra3FlxxERrMcnRAmxQKE8paXOIwQ88zpafUkzdOYg9Sn/3/Vf4EqCWv8P/hkNIlfW/8VYsk8fk+3DQPVxQ== +gridstack@11.1.2: + version "11.1.2" + resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.1.2.tgz#e72091e2883f7b37cbd150c218d38eebc9fc4f18" + integrity sha512-6wJ5RffnFchF63/Yhs6tcZcWxRG1EgCnxgejbQsAjQ6Qj8QqKjew73jPq5c1yCAiyEAsXxI2tOJ8lZABOAZxoQ== has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" @@ -2661,10 +2661,10 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" -sass@1.81.0: - version "1.81.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.81.0.tgz#a9010c0599867909dfdbad057e4a6fbdd5eec941" - integrity sha512-Q4fOxRfhmv3sqCLoGfvrC9pRV8btc0UtqL9mN6Yrv6Qi9ScL55CVH1vlPP863ISLEEMNLLuu9P+enCeGHlnzhA== +sass@1.82.0: + version "1.82.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.82.0.tgz#30da277af3d0fa6042e9ceabd0d984ed6d07df70" + integrity sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q== dependencies: chokidar "^4.0.0" immutable "^5.0.2" diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 693a1a017..9dcb7bded 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -19,7 +19,7 @@ Blocks:
{# Sidebar #} -