Просмотр исходного кода

Merge pull request #11376 from netbox-community/develop

Release v3.4.2
Jeremy Stretch 3 лет назад
Родитель
Сommit
04137e887e
34 измененных файлов с 403 добавлено и 59 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 3 0
      docs/configuration/required-parameters.md
  4. 8 0
      docs/configuration/system.md
  5. 23 0
      docs/release-notes/version-3.4.md
  6. 1 0
      netbox/dcim/filtersets.py
  7. 29 1
      netbox/dcim/forms/bulk_import.py
  8. 5 4
      netbox/dcim/forms/common.py
  9. 99 12
      netbox/dcim/forms/model_forms.py
  10. 5 0
      netbox/dcim/models/device_components.py
  11. 1 1
      netbox/dcim/models/devices.py
  12. 3 0
      netbox/dcim/tables/devices.py
  13. 3 11
      netbox/dcim/views.py
  14. 0 1
      netbox/extras/forms/model_forms.py
  15. 20 9
      netbox/extras/management/commands/reindex.py
  16. 10 1
      netbox/extras/migrations/0083_search.py
  17. 6 1
      netbox/extras/models/models.py
  18. 3 0
      netbox/ipam/tables/fhrp.py
  19. 5 0
      netbox/netbox/configuration_example.py
  20. 2 0
      netbox/netbox/configuration_testing.py
  21. 2 2
      netbox/netbox/search/backends.py
  22. 17 2
      netbox/netbox/settings.py
  23. 1 1
      netbox/templates/dcim/consoleport.html
  24. 1 1
      netbox/templates/dcim/consoleserverport.html
  25. 1 1
      netbox/templates/dcim/inc/connection_endpoints.html
  26. 1 1
      netbox/templates/dcim/interface.html
  27. 106 0
      netbox/templates/dcim/inventoryitem_edit.html
  28. 1 1
      netbox/templates/dcim/powerfeed.html
  29. 1 1
      netbox/templates/dcim/poweroutlet.html
  30. 1 1
      netbox/templates/dcim/powerport.html
  31. 4 0
      netbox/templates/dcim/rack_elevation_list.html
  32. 31 0
      netbox/templates/virtualization/cluster.html
  33. 4 1
      netbox/virtualization/views.py
  34. 4 4
      requirements.txt

+ 1 - 1
.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.4.1
+      placeholder: v3.4.2
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.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.4.1
+      placeholder: v3.4.2
     validations:
       required: true
   - type: dropdown

+ 3 - 0
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,

+ 8 - 0
docs/configuration/system.md

@@ -65,6 +65,14 @@ Email is sent from NetBox only for critical events or if configured for [logging
 
 ---
 
+## ENABLE_LOCALIZATION
+
+Default: False
+
+Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding what is set for DATE_FORMAT) based on the browser locale as well as translate certain strings from third party modules.
+
+---
+
 ## HTTP_PROXIES
 
 Default: None

+ 23 - 0
docs/release-notes/version-3.4.md

@@ -1,5 +1,28 @@
 # NetBox v3.4
 
+## v3.4.2 (2023-01-03)
+
+### Enhancements
+
+* [#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
+
+---
+
 ## v3.4.1 (2022-12-16)
 
 ### Enhancements

+ 1 - 0
netbox/dcim/filtersets.py

@@ -1098,6 +1098,7 @@ class ModuleFilterSet(NetBoxModelFilterSet):
         if not value.strip():
             return queryset
         return queryset.filter(
+            Q(device__name__icontains=value.strip()) |
             Q(serial__icontains=value.strip()) |
             Q(asset_tag__icontains=value.strip()) |
             Q(comments__icontains=value)

+ 29 - 1
netbox/dcim/forms/bulk_import.py

@@ -885,12 +885,22 @@ class InventoryItemImportForm(NetBoxModelImportForm):
         required=False,
         help_text=_('Parent inventory item')
     )
+    component_type = CSVContentTypeField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=MODULAR_COMPONENT_MODELS,
+        required=False,
+        help_text=_('Component Type')
+    )
+    component_name = forms.CharField(
+        required=False,
+        help_text=_('Component Name')
+    )
 
     class Meta:
         model = InventoryItem
         fields = (
             'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
-            'description', 'tags'
+            'description', 'tags', 'component_type', 'component_name',
         )
 
     def __init__(self, *args, **kwargs):
@@ -908,6 +918,24 @@ class InventoryItemImportForm(NetBoxModelImportForm):
         else:
             self.fields['parent'].queryset = InventoryItem.objects.none()
 
+    def clean_component_name(self):
+        content_type = self.cleaned_data.get('component_type')
+        component_name = self.cleaned_data.get('component_name')
+        device = self.cleaned_data.get("device")
+
+        if not device and hasattr(self, 'instance'):
+            device = self.instance.device
+
+        if not all([device, content_type, component_name]):
+            return None
+
+        model = content_type.model_class()
+        try:
+            component = model.objects.get(device=device, name=component_name)
+            self.instance.component = component
+        except ObjectDoesNotExist:
+            raise forms.ValidationError(f"Component not found: {device} - {component_name}")
+
 
 #
 # Device component roles

+ 5 - 4
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
 

+ 99 - 12
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
 #
 

+ 5 - 0
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"
+                })

+ 1 - 1
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})."
             )

+ 3 - 0
netbox/dcim/tables/devices.py

@@ -506,6 +506,9 @@ class BaseInterfaceTable(NetBoxTable):
         verbose_name='Tagged VLANs'
     )
 
+    def value_ip_addresses(self, value):
+        return ",".join([str(obj.address) for obj in value.all()])
+
 
 class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
     device = tables.Column(

+ 3 - 11
netbox/dcim/views.py

@@ -691,6 +691,7 @@ class RackElevationListView(generic.ObjectListView):
             'sort_choices': ORDERING_CHOICES,
             'rack_face': rack_face,
             'filter_form': forms.RackElevationFilterForm(request.GET),
+            'model': self.queryset.model,
         })
 
 
@@ -2913,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')

+ 0 - 1
netbox/extras/forms/model_forms.py

@@ -32,7 +32,6 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         limit_choices_to=FeatureQuery('custom_fields'),
-        label=_('Model(s)')
     )
     object_type = ContentTypeChoiceField(
         queryset=ContentType.objects.all(),

+ 20 - 9
netbox/extras/management/commands/reindex.py

@@ -27,17 +27,28 @@ class Command(BaseCommand):
         # Return only indexers for the specified models
         else:
             for label in model_names:
-                try:
-                    app_label, model_name = label.lower().split('.')
-                except ValueError:
+                labels = label.lower().split('.')
+
+                # Label specifies an exact model
+                if len(labels) == 2:
+                    app_label, model_name = labels
+                    try:
+                        idx = registry['search'][f'{app_label}.{model_name}']
+                        indexers[idx.model] = idx
+                    except KeyError:
+                        raise CommandError(f"No indexer registered for {label}")
+
+                # Label specifies all the models of an app
+                elif len(labels) == 1:
+                    app_label = labels[0] + '.'
+                    for indexer_label, idx in registry['search'].items():
+                        if indexer_label.startswith(app_label):
+                            indexers[idx.model] = idx
+
+                else:
                     raise CommandError(
-                        f"Invalid model: {label}. Model names must be in the format <app_label>.<model_name>."
+                        f"Invalid model: {label}. Model names must be in the format <app_label> or <app_label>.<model_name>."
                     )
-                try:
-                    idx = registry['search'][f'{app_label}.{model_name}']
-                    indexers[idx.model] = idx
-                except KeyError:
-                    raise CommandError(f"No indexer registered for {label}")
 
         return indexers
 

+ 10 - 1
netbox/extras/migrations/0083_search.py

@@ -10,7 +10,16 @@ from django.db import migrations, models
 def reindex(apps, schema_editor):
     # Build the search index (except during tests)
     if 'test' not in sys.argv:
-        management.call_command('reindex')
+        management.call_command(
+            'reindex',
+            'circuits',
+            'dcim',
+            'extras',
+            'ipam',
+            'tenancy',
+            'virtualization',
+            'wireless',
+        )
 
 
 class Migration(migrations.Migration):

+ 6 - 1
netbox/extras/models/models.py

@@ -651,7 +651,12 @@ class JobResult(models.Model):
         if not self.completed:
             return None
 
-        duration = self.completed - self.created
+        start_time = self.started or self.created
+
+        if not start_time:
+            return None
+
+        duration = self.completed - start_time
         minutes, seconds = divmod(duration.total_seconds(), 60)
 
         return f"{int(minutes)} minutes, {seconds:.2f} seconds"

+ 3 - 0
netbox/ipam/tables/fhrp.py

@@ -33,6 +33,9 @@ class FHRPGroupTable(NetBoxTable):
         url_name='ipam:fhrpgroup_list'
     )
 
+    def value_ip_addresses(self, value):
+        return ",".join([str(obj.address) for obj in value.all()])
+
     class Meta(NetBoxTable.Meta):
         model = FHRPGroup
         fields = (

+ 5 - 0
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,
@@ -222,6 +224,9 @@ SESSION_COOKIE_NAME = 'sessionid'
 # database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
 SESSION_FILE_PATH = None
 
+# Localization
+ENABLE_LOCALIZATION = False
+
 # Time zone (default: UTC)
 TIME_ZONE = 'UTC'
 

+ 2 - 0
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,

+ 2 - 2
netbox/netbox/search/backends.py

@@ -99,8 +99,8 @@ class CachedValueSearchBackend(SearchBackend):
         params = {
             f'value__{lookup}': value
         }
-        if lookup != LookupTypes.EXACT:
-            # Partial matches are valid only on string values
+        if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
+            # Partial string matches are valid only on string values
             params['type'] = FieldTypes.STRING
         if object_types:
             params['object_type__in'] = object_types

+ 17 - 2
netbox/netbox/settings.py

@@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 #
 
-VERSION = '3.4.1'
+VERSION = '3.4.2'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -137,6 +137,7 @@ STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
 STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
 TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
 TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
+ENABLE_LOCALIZATION = getattr(configuration, 'ENABLE_LOCALIZATION', False)
 
 # Check for hard-coded dynamic config parameters
 for param in PARAMS:
@@ -229,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)
@@ -242,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')
@@ -251,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,
@@ -356,6 +360,9 @@ MIDDLEWARE = [
     'django_prometheus.middleware.PrometheusAfterMiddleware',
 ]
 
+if not ENABLE_LOCALIZATION:
+    MIDDLEWARE.remove("django.middleware.locale.LocaleMiddleware")
+
 ROOT_URLCONF = 'netbox.urls'
 
 TEMPLATES_DIR = BASE_DIR + '/templates'
@@ -636,6 +643,7 @@ else:
     }
 RQ_PARAMS.update({
     'DB': TASKS_REDIS_DATABASE,
+    'USERNAME': TASKS_REDIS_USERNAME,
     'PASSWORD': TASKS_REDIS_PASSWORD,
     'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
 })
@@ -651,6 +659,13 @@ RQ_QUEUES.update({
     queue: RQ_PARAMS for queue in set(QUEUE_MAPPINGS.values()) if queue not in RQ_QUEUES
 })
 
+#
+# Localization
+#
+
+if not ENABLE_LOCALIZATION:
+    USE_I18N = False
+    USE_L10N = False
 
 #
 # Plugins

+ 1 - 1
netbox/templates/dcim/consoleport.html

@@ -60,7 +60,7 @@
               {% if object.mark_connected %}
                 <span class="text-success"><i class="mdi mdi-check-bold"></i></span> 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 %}
                 <div class="text-muted">
                   Not Connected

+ 1 - 1
netbox/templates/dcim/consoleserverport.html

@@ -60,7 +60,7 @@
               {% if object.mark_connected %}
                 <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
               {% elif object.cable %}
-                {% include 'dcim/inc/connection_endpoints.html' %}
+                {% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:consoleserverport_trace' %}
               {% else %}
                 <div class="text-muted">
                   Not Connected

+ 1 - 1
netbox/templates/dcim/inc/connection_endpoints.html

@@ -3,7 +3,7 @@
     <th scope="row">Cable</th>
     <td>
       {{ object.cable|linkify }}
-      <a href="{% url 'dcim:interface_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
+      <a href="{% url trace_url pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
         <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
       </a>
     </td>

+ 1 - 1
netbox/templates/dcim/interface.html

@@ -145,7 +145,7 @@
                 <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
               </div>
             {% elif object.cable %}
-              {% include 'dcim/inc/connection_endpoints.html' %}
+              {% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:interface_trace' %}
             {% elif object.wireless_link %}
               <table class="table table-hover">
                 <tr>

+ 106 - 0
netbox/templates/dcim/inventoryitem_edit.html

@@ -0,0 +1,106 @@
+{% extends 'generic/object_edit.html' %}
+{% load static %}
+{% load form_helpers %}
+{% load helpers %}
+
+{% block form %}
+    <div class="field-group my-5">
+      <div class="row mb-2">
+        <h5 class="offset-sm-3">InventoryItem</h5>
+      </div>
+      {% render_field form.device %}
+      {% render_field form.parent %}
+      {% render_field form.name %}
+      {% render_field form.label %}
+      {% render_field form.role %}
+      {% render_field form.description %}
+      {% render_field form.tags %}
+    </div>
+
+    <div class="field-group my-5">
+      <div class="row mb-2">
+        <h5 class="offset-sm-3">Hardware</h5>
+      </div>
+      {% render_field form.manufacturer %}
+      {% render_field form.part_id %}
+      {% render_field form.serial %}
+      {% render_field form.asset_tag %}
+    </div>
+
+    <div class="field-group my-5">
+      <div class="row mb-2">
+        <h5 class="offset-sm-3">Component Assignment</h5>
+      </div>
+      <div class="row mb-2 offset-sm-3">
+        <ul class="nav nav-pills" role="tablist">
+          <li role="presentation" class="nav-item">
+              <button role="tab" type="button" id="consoleport_tab" data-bs-toggle="tab" aria-controls="consoleport" data-bs-target="#consoleport" class="nav-link {% if form.initial.consoleport or form.no_component %}active{% endif %}">
+                Console Port
+              </button>
+            </li>
+            <li role="presentation" class="nav-item">
+              <button role="tab" type="button" id="consoleserverport_tab" data-bs-toggle="tab" aria-controls="consoleserverport" data-bs-target="#consoleserverport" class="nav-link {% if form.initial.consoleserverport %}active{% endif %}">
+                Console Server Port
+              </button>
+            </li>
+            <li role="presentation" class="nav-item">
+              <button role="tab" type="button" id="frontport_tab" data-bs-toggle="tab" aria-controls="frontport" data-bs-target="#frontport" class="nav-link {% if form.initial.frontport %}active{% endif %}">
+                Front Port
+              </button>
+            </li>
+            <li role="presentation" class="nav-item">
+              <button role="tab" type="button" id="interface_tab" data-bs-toggle="tab" aria-controls="interface" data-bs-target="#interface" class="nav-link {% if form.initial.interface %}active{% endif %}">
+                Interface
+              </button>
+            </li>
+            <li role="presentation" class="nav-item">
+              <button role="tab" type="button" id="poweroutlet_tab" data-bs-toggle="tab" aria-controls="poweroutlet" data-bs-target="#poweroutlet" class="nav-link {% if form.initial.poweroutlet %}active{% endif %}">
+                Power Outlet
+              </button>
+            </li>
+            <li role="presentation" class="nav-item">
+              <button role="tab" type="button" id="powerport_tab" data-bs-toggle="tab" aria-controls="powerport" data-bs-target="#powerport" class="nav-link {% if form.initial.powerport %}active{% endif %}">
+                Power Port
+              </button>
+            </li>
+            <li role="presentation" class="nav-item">
+              <button role="tab" type="button" id="rearport_tab" data-bs-toggle="tab" aria-controls="rearport" data-bs-target="#rearport" class="nav-link {% if form.initial.rearport %}active{% endif %}">
+                Rear Port
+              </button>
+            </li>
+        </ul>
+      </div>
+      <div class="tab-content p-0 border-0">
+        <div class="tab-pane {% if form.initial.consoleport or form.no_component %}active{% endif %}" id="consoleport" role="tabpanel" aria-labeled-by="consoleport_tab">
+            {% render_field form.consoleport %}
+          </div>
+          <div class="tab-pane {% if form.initial.consoleserverport %}active{% endif %}" id="consoleserverport" role="tabpanel" aria-labeled-by="consoleserverport_tab">
+            {% render_field form.consoleserverport %}
+          </div>
+          <div class="tab-pane {% if form.initial.frontport %}active{% endif %}" id="frontport" role="tabpanel" aria-labeled-by="frontport_tab">
+            {% render_field form.frontport %}
+          </div>
+          <div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">
+            {% render_field form.interface %}
+          </div>
+          <div class="tab-pane {% if form.initial.poweroutlet %}active{% endif %}" id="poweroutlet" role="tabpanel" aria-labeled-by="poweroutlet_tab">
+            {% render_field form.poweroutlet %}
+          </div>
+          <div class="tab-pane {% if form.initial.powerport %}active{% endif %}" id="powerport" role="tabpanel" aria-labeled-by="powerport_tab">
+            {% render_field form.powerport %}
+          </div>
+          <div class="tab-pane {% if form.initial.rearport %}active{% endif %}" id="rearport" role="tabpanel" aria-labeled-by="rearport_tab">
+            {% render_field form.rearport %}
+          </div>
+      </div>
+    </div>
+
+    {% if form.custom_fields %}
+      <div class="field-group my-5">
+        <div class="row mb-2">
+          <h5 class="offset-sm-3">Custom Fields</h5>
+        </div>
+        {% render_custom_fields form %}
+      </div>
+    {% endif %}
+{% endblock %}

+ 1 - 1
netbox/templates/dcim/powerfeed.html

@@ -112,7 +112,7 @@
               <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
             </div>
           {% elif object.cable %}
-            {% include 'dcim/inc/connection_endpoints.html' %}
+            {% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:powerfeed_trace' %}
           {% else %}
             <div class="text-muted">
               Not connected

+ 1 - 1
netbox/templates/dcim/poweroutlet.html

@@ -66,7 +66,7 @@
                   <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
                 </div>
               {% elif object.cable %}
-                {% include 'dcim/inc/connection_endpoints.html' %}
+                {% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:poweroutlet_trace' %}
               {% else %}
                 <div class="text-muted">
                   Not Connected

+ 1 - 1
netbox/templates/dcim/powerport.html

@@ -66,7 +66,7 @@
                   <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
                 </div>
               {% elif object.cable %}
-                {% include 'dcim/inc/connection_endpoints.html' %}
+                {% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:powerport_trace' %}
               {% else %}
                 <div class="text-muted">
                   Not Connected

+ 4 - 0
netbox/templates/dcim/rack_elevation_list.html

@@ -35,6 +35,10 @@
 {% block content-wrapper %}
   <div class="tab-content">
 
+    {% if filter_form %}
+      {% applied_filters model filter_form request.GET %}
+    {% endif %}
+    
     {# Rack elevations #}
     <div class="tab-pane show active" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
       {% if page %}

+ 31 - 0
netbox/templates/virtualization/cluster.html

@@ -55,6 +55,37 @@
     {% plugin_left_page object %}
   </div>
   <div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">Allocated Resources</h5>
+      <div class="card-body">
+          <table class="table table-hover attr-table">
+              <tr>
+                  <th scope="row"><i class="mdi mdi-gauge"></i> Virtual CPUs</th>
+                  <td>{{ vcpus_sum|placeholder }}</td>
+              </tr>
+              <tr>
+                  <th scope="row"><i class="mdi mdi-chip"></i> Memory</th>
+                  <td>
+                      {% if memory_sum %}
+                          {{ memory_sum|humanize_megabytes }}
+                      {% else %}
+                          {{ ''|placeholder }}
+                      {% endif %}
+                  </td>
+              </tr>
+              <tr>
+                  <th scope="row"><i class="mdi mdi-harddisk"></i> Disk Space</th>
+                  <td>
+                      {% if disk_sum %}
+                          {{ disk_sum }} GB
+                      {% else %}
+                          {{ ''|placeholder }}
+                      {% endif %}
+                  </td>
+              </tr>
+          </table>
+      </div>
+  </div>
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/tags.html' %}
     {% include 'inc/panels/contacts.html' %}

+ 4 - 1
netbox/virtualization/views.py

@@ -1,6 +1,6 @@
 from django.contrib import messages
 from django.db import transaction
-from django.db.models import Prefetch
+from django.db.models import Prefetch, Sum
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils.translation import gettext as _
@@ -169,6 +169,9 @@ class ClusterListView(generic.ObjectListView):
 class ClusterView(generic.ObjectView):
     queryset = Cluster.objects.all()
 
+    def get_extra_context(self, request, instance):
+        return instance.virtual_machines.aggregate(vcpus_sum=Sum('vcpus'), memory_sum=Sum('memory'), disk_sum=Sum('disk'))
+
 
 @register_model_view(Cluster, 'virtualmachines', path='virtual-machines')
 class ClusterVirtualMachinesView(generic.ObjectChildrenView):

+ 4 - 4
requirements.txt

@@ -1,5 +1,5 @@
 bleach==5.0.1
-Django==4.1.4
+Django==4.1.5
 django-cors-headers==3.13.0
 django-debug-toolbar==3.8.1
 django-filter==22.1
@@ -10,7 +10,7 @@ django-prometheus==2.2.0
 django-redis==5.2.0
 django-rich==1.4.0
 django-rq==2.6.0
-django-tables2==2.4.1
+django-tables2==2.5.0
 django-taggit==3.1.0
 django-timezone-field==5.0
 djangorestframework==3.14.0
@@ -22,10 +22,10 @@ Markdown==3.3.7
 mkdocs-material==8.5.11
 mkdocstrings[python-legacy]==0.19.1
 netaddr==0.8.0
-Pillow==9.3.0
+Pillow==9.4.0
 psycopg2-binary==2.9.5
 PyYAML==6.0
-sentry-sdk==1.11.1
+sentry-sdk==1.12.1
 social-auth-app-django==5.0.0
 social-auth-core[openidconnect]==4.3.0
 svgwrite==1.4.3