diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 15f743754..a71a1b410 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -63,6 +63,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes * `HOST` - Name or IP address of the Redis server (use `localhost` if running locally) * `PORT` - TCP port of the Redis service; leave blank for default port (6379) +* `USERNAME` - Redis username (if set) * `PASSWORD` - Redis password (if set) * `DATABASE` - Numeric database ID * `SSL` - Use SSL connection to Redis @@ -75,6 +76,7 @@ REDIS = { 'tasks': { 'HOST': 'redis.example.com', 'PORT': 1234, + 'USERNAME': 'netbox' 'PASSWORD': 'foobar', 'DATABASE': 0, 'SSL': False, @@ -82,6 +84,7 @@ REDIS = { 'caching': { 'HOST': 'localhost', 'PORT': 6379, + 'USERNAME': '' 'PASSWORD': '', 'DATABASE': 1, 'SSL': False, diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index fb2eec5dd..94d67a36f 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -7,15 +7,19 @@ * [#9285](https://github.com/netbox-community/netbox/issues/9285) - Enable specifying assigned component during bulk import of inventory items * [#10700](https://github.com/netbox-community/netbox/issues/10700) - Match device name when using modules quick search * [#11121](https://github.com/netbox-community/netbox/issues/11121) - Add VM resource totals to cluster view +* [#11156](https://github.com/netbox-community/netbox/issues/11156) - Enable selecting assigned component when editing inventory item in UI * [#11223](https://github.com/netbox-community/netbox/issues/11223) - `reindex` management command should accept app label without model name * [#11244](https://github.com/netbox-community/netbox/issues/11244) - Add controls for saved filters to rack elevations list * [#11248](https://github.com/netbox-community/netbox/issues/11248) - Fix database migration when plugin with search indexer is enabled +* [#11259](https://github.com/netbox-community/netbox/issues/11259) - Add support for Redis username configuration ### Bug Fixes * [#11280](https://github.com/netbox-community/netbox/issues/11280) - Fix errant newlines when exporting interfaces with multiple IP addresses assigned * [#11290](https://github.com/netbox-community/netbox/issues/11290) - Correct reporting of scheduled job duration * [#11232](https://github.com/netbox-community/netbox/issues/11232) - Enable partial & regular expression matching for non-string types in global search +* [#11342](https://github.com/netbox-community/netbox/issues/11342) - Correct cable trace URL under "connection" tab for device components +* [#11345](https://github.com/netbox-community/netbox/issues/11345) - Fix form validation for bulk import of modules --- diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index d479916d9..a2243ce2d 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -56,8 +56,8 @@ class ModuleCommonForm(forms.Form): def clean(self): super().clean() - replicate_components = self.cleaned_data.get("replicate_components") - adopt_components = self.cleaned_data.get("adopt_components") + replicate_components = self.cleaned_data.get('replicate_components') + adopt_components = self.cleaned_data.get('adopt_components') device = self.cleaned_data.get('device') module_type = self.cleaned_data.get('module_type') module_bay = self.cleaned_data.get('module_bay') @@ -65,8 +65,9 @@ class ModuleCommonForm(forms.Form): if adopt_components: self.instance._adopt_components = True - # Bail out if we are not installing a new module or if we are not replicating components - if self.instance.pk or not replicate_components: + # Bail out if we are not installing a new module or if we are not replicating components (or if + # validation has already failed) + if self.errors or self.instance.pk or not replicate_components: self.instance._disable_replication = True return diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 1614f4bae..91e0266f0 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1549,15 +1549,63 @@ class InventoryItemForm(DeviceComponentForm): queryset=Manufacturer.objects.all(), required=False ) - component_type = ContentTypeChoiceField( - queryset=ContentType.objects.all(), - limit_choices_to=MODULAR_COMPONENT_MODELS, + + # Assigned component selectors + consoleport = DynamicModelChoiceField( + queryset=ConsolePort.objects.all(), required=False, - widget=forms.HiddenInput + query_params={ + 'device_id': '$device' + }, + label=_('Console port') ) - component_id = forms.IntegerField( + consoleserverport = DynamicModelChoiceField( + queryset=ConsoleServerPort.objects.all(), required=False, - widget=forms.HiddenInput + query_params={ + 'device_id': '$device' + }, + label=_('Console server port') + ) + frontport = DynamicModelChoiceField( + queryset=FrontPort.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + }, + label=_('Front port') + ) + interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + }, + label=_('Interface') + ) + poweroutlet = DynamicModelChoiceField( + queryset=PowerOutlet.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + }, + label=_('Power outlet') + ) + powerport = DynamicModelChoiceField( + queryset=PowerPort.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + }, + label=_('Power port') + ) + rearport = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + }, + label=_('Rear port') ) fieldsets = ( @@ -1565,22 +1613,61 @@ class InventoryItemForm(DeviceComponentForm): ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), ) + class Meta: + model = InventoryItem + fields = [ + 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'description', 'tags', + ] + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + component_type = initial.get('component_type') + component_id = initial.get('component_id') + + # Used for picking the default active tab for component selection + self.no_component = True + + if instance: + # When editing set the initial value for component selectin + for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS): + if type(instance.component) is component_model.model_class(): + initial[component_model.model] = instance.component + self.no_component = False + break + elif component_type and component_id: + # When adding the InventoryItem from a component page + if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first(): + if component := content_type.model_class().objects.filter(pk=component_id).first(): + initial[content_type.model] = component + self.no_component = False + + kwargs['initial'] = initial + super().__init__(*args, **kwargs) # Specifically allow editing the device of IntentoryItems if self.instance.pk: self.fields['device'].disabled = False - class Meta: - model = InventoryItem - fields = [ - 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'description', 'component_type', 'component_id', 'tags', + def clean(self): + super().clean() + + # Handle object assignment + selected_objects = [ + field for field in ( + 'consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport' + ) if self.cleaned_data[field] ] + if len(selected_objects) > 1: + raise forms.ValidationError("An InventoryItem can only be assigned to a single component.") + elif selected_objects: + self.instance.component = self.cleaned_data[selected_objects[0]] + else: + self.instance.component = None -# # Device component roles # diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 177c21edb..0d4ca82ea 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1146,3 +1146,8 @@ class InventoryItem(MPTTModel, ComponentModel): # When moving an InventoryItem to another device, remove any associated component if self.component and self.component.device != self.device: self.component = None + else: + if self.component and self.component.device != self.device: + raise ValidationError({ + "device": "Cannot assign inventory item to component on another device" + }) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 53c6d12a7..603129228 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -961,7 +961,7 @@ class Module(PrimaryModel, ConfigContextModel): def clean(self): super().clean() - if self.module_bay.device != self.device: + if hasattr(self, "module_bay") and (self.module_bay.device != self.device): raise ValidationError( f"Module must be installed within a module bay belonging to the assigned device ({self.device})." ) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 870cbcc18..115c16112 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2914,23 +2914,14 @@ class InventoryItemView(generic.ObjectView): class InventoryItemEditView(generic.ObjectEditView): queryset = InventoryItem.objects.all() form = forms.InventoryItemForm + template_name = 'dcim/inventoryitem_edit.html' class InventoryItemCreateView(generic.ComponentCreateView): queryset = InventoryItem.objects.all() form = forms.InventoryItemCreateForm model_form = forms.InventoryItemForm - - def alter_object(self, instance, request): - # Set component (if any) - component_type = request.GET.get('component_type') - component_id = request.GET.get('component_id') - - if component_type and component_id: - content_type = get_object_or_404(ContentType, pk=component_type) - instance.component = get_object_or_404(content_type.model_class(), pk=component_id) - - return instance + template_name = 'dcim/inventoryitem_edit.html' @register_model_view(InventoryItem, 'delete') diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index 9d9651462..262b52d4f 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -31,6 +31,7 @@ REDIS = { # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel # 'SENTINELS': [('mysentinel.redis.example.com', 6379)], # 'SENTINEL_SERVICE': 'netbox', + 'USERNAME': '', 'PASSWORD': '', 'DATABASE': 0, 'SSL': False, @@ -44,6 +45,7 @@ REDIS = { # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel # 'SENTINELS': [('mysentinel.redis.example.com', 6379)], # 'SENTINEL_SERVICE': 'netbox', + 'USERNAME': '', 'PASSWORD': '', 'DATABASE': 1, 'SSL': False, diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 621671f04..26d768004 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -22,6 +22,7 @@ REDIS = { 'tasks': { 'HOST': 'localhost', 'PORT': 6379, + 'USERNAME': '', 'PASSWORD': '', 'DATABASE': 0, 'SSL': False, @@ -29,6 +30,7 @@ REDIS = { 'caching': { 'HOST': 'localhost', 'PORT': 6379, + 'USERNAME': '', 'PASSWORD': '', 'DATABASE': 1, 'SSL': False, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3a494093b..b4f4ac15d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -230,6 +230,7 @@ TASKS_REDIS_USING_SENTINEL = all([ ]) TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default') TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10) +TASKS_REDIS_USERNAME = TASKS_REDIS.get('USERNAME', '') TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '') TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False) @@ -243,6 +244,8 @@ if 'caching' not in REDIS: CACHING_REDIS_HOST = REDIS['caching'].get('HOST', 'localhost') CACHING_REDIS_PORT = REDIS['caching'].get('PORT', 6379) CACHING_REDIS_DATABASE = REDIS['caching'].get('DATABASE', 0) +CACHING_REDIS_USERNAME = REDIS['caching'].get('USERNAME', '') +CACHING_REDIS_USERNAME_HOST = '@'.join(filter(None, [CACHING_REDIS_USERNAME, CACHING_REDIS_HOST])) CACHING_REDIS_PASSWORD = REDIS['caching'].get('PASSWORD', '') CACHING_REDIS_SENTINELS = REDIS['caching'].get('SENTINELS', []) CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'default') @@ -252,7 +255,7 @@ CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY', CACHES = { 'default': { 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}', + 'LOCATION': f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}', 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 'PASSWORD': CACHING_REDIS_PASSWORD, @@ -640,6 +643,7 @@ else: } RQ_PARAMS.update({ 'DB': TASKS_REDIS_DATABASE, + 'USERNAME': TASKS_REDIS_USERNAME, 'PASSWORD': TASKS_REDIS_PASSWORD, 'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT, }) diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index ad4f15c9d..dee57e28b 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -60,7 +60,7 @@ {% if object.mark_connected %} Marked as connected {% elif object.cable %} - {% include 'dcim/inc/connection_endpoints.html' %} + {% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:consoleport_trace' %} {% else %}