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

Merge pull request #9131 from netbox-community/develop

Release v3.2.1
Jeremy Stretch 3 лет назад
Родитель
Сommit
7cd9bcd3f5
47 измененных файлов с 450 добавлено и 146 удалено
  1. 2 2
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 0
      docs/administration/housekeeping.md
  4. 12 0
      docs/configuration/dynamic-settings.md
  5. 1 1
      docs/configuration/remote-authentication.md
  6. 6 0
      docs/customization/custom-scripts.md
  7. 14 0
      docs/customization/reports.md
  8. 2 1
      docs/models/dcim/virtualchassis.md
  9. 30 0
      docs/release-notes/version-3.2.md
  10. 16 0
      netbox/dcim/choices.py
  11. 10 10
      netbox/dcim/filtersets.py
  12. 5 1
      netbox/dcim/forms/bulk_edit.py
  13. 5 5
      netbox/dcim/forms/bulk_import.py
  14. 3 3
      netbox/dcim/forms/models.py
  15. 6 1
      netbox/dcim/forms/object_create.py
  16. 12 7
      netbox/dcim/models/device_component_templates.py
  17. 9 0
      netbox/dcim/models/device_components.py
  18. 1 0
      netbox/dcim/models/devices.py
  19. 1 1
      netbox/dcim/models/sites.py
  20. 14 2
      netbox/dcim/tables/devices.py
  21. 15 7
      netbox/dcim/views.py
  22. 1 1
      netbox/extras/admin.py
  23. 6 4
      netbox/extras/api/views.py
  24. 28 0
      netbox/extras/management/commands/housekeeping.py
  25. 2 1
      netbox/extras/management/commands/runreport.py
  26. 0 7
      netbox/extras/management/commands/runscript.py
  27. 3 1
      netbox/extras/models/models.py
  28. 1 9
      netbox/extras/reports.py
  29. 5 11
      netbox/extras/scripts.py
  30. 7 4
      netbox/extras/views.py
  31. 16 1
      netbox/ipam/filtersets.py
  32. 4 0
      netbox/ipam/forms/filtersets.py
  33. 2 1
      netbox/ipam/models/__init__.py
  34. 20 5
      netbox/ipam/tests/test_filtersets.py
  35. 38 1
      netbox/netbox/authentication.py
  36. 7 0
      netbox/netbox/config/parameters.py
  37. 1 1
      netbox/netbox/settings.py
  38. 1 10
      netbox/netbox/views/__init__.py
  39. 41 27
      netbox/templates/dcim/inc/nonracked_devices.html
  40. 12 0
      netbox/templates/dcim/modulebay.html
  41. 10 0
      netbox/templates/dcim/site.html
  42. 6 4
      netbox/templates/login.html
  43. 6 1
      netbox/users/views.py
  44. 5 5
      netbox/utilities/forms/utils.py
  45. 12 0
      netbox/virtualization/forms/bulk_import.py
  46. 47 7
      netbox/wireless/forms/models.py
  47. 3 3
      requirements.txt

+ 2 - 2
.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.2.0
+      placeholder: v3.2.1
     validations:
       required: true
   - type: dropdown
@@ -22,9 +22,9 @@ body:
       label: Python version
       description: What version of Python are you currently running?
       options:
-        - "3.7"
         - "3.8"
         - "3.9"
+        - "3.10"
     validations:
       required: true
   - type: textarea

+ 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.2.0
+      placeholder: v3.2.1
     validations:
       required: true
   - type: dropdown

+ 1 - 0
docs/administration/housekeeping.md

@@ -4,6 +4,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
 
 * Clearing expired authentication sessions from the database
 * Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention)
+* Deleting job result records older than the configured [retention time](../configuration/dynamic-settings.md#jobresult_retention)
 
 This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
 

+ 12 - 0
docs/configuration/dynamic-settings.md

@@ -43,6 +43,18 @@ changes in the database indefinitely.
 
 ---
 
+## JOBRESULT_RETENTION
+
+Default: 90
+
+The number of days to retain job results (scripts and reports). Set this to `0` to retain
+job results in the database indefinitely.
+
+!!! warning
+    If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
+
+---
+
 ## CUSTOM_VALIDATORS
 
 This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:

+ 1 - 1
docs/configuration/remote-authentication.md

@@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
 
 Default: `False`
 
-NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled)
+NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is disabled)
 
 ---
 

+ 6 - 0
docs/customization/custom-scripts.md

@@ -89,6 +89,12 @@ The checkbox to commit database changes when executing a script is checked by de
 commit_default = False
 ```
 
+### `job_timeout`
+
+Set the maximum allowed runtime for the script. If not set, `RQ_DEFAULT_TIMEOUT` will be used.
+
+!!! info "This feature was introduced in v3.2.1"
+
 ## Accessing Request Data
 
 Details of the current HTTP request (the one being made to execute the script) are available as the instance attribute `self.request`. This can be used to infer, for example, the user executing the script and the client IP address:

+ 14 - 0
docs/customization/reports.md

@@ -85,6 +85,20 @@ As you can see, reports are completely customizable. Validation logic can be as
 !!! warning
     Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data.
 
+## Report Attributes
+
+### `description`
+
+A human-friendly description of what your report does.
+
+### `job_timeout`
+
+Set the maximum allowed runtime for the report. If not set, `RQ_DEFAULT_TIMEOUT` will be used.
+
+!!! info "This feature was introduced in v3.2.1"
+
+## Logging
+
 The following methods are available to log results within a report:
 
 * log(message)

+ 2 - 1
docs/models/dcim/virtualchassis.md

@@ -2,7 +2,8 @@
 
 A virtual chassis represents a set of devices which share a common control plane. A common example of this is a stack of switches which are connected and configured to operate as a single device. A virtual chassis must be assigned a name and may be assigned a domain.
 
-Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, services, and other attributes related to managing the VC.
+Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, services, virtual interfaces, and other attributes related to managing the VC.
+If a VC master is defined, interfaces from all VC members are displayed when navigating to its device interfaces view. This does not include other members interfaces declared as management-only.
 
 !!! note
     It's important to recognize the distinction between a virtual chassis and a chassis-based device. A virtual chassis is **not** suitable for modeling a chassis-based switch with removable line cards (such as the Juniper EX9208), as its line cards are _not_ physically autonomous devices.

+ 30 - 0
docs/release-notes/version-3.2.md

@@ -1,5 +1,35 @@
 # NetBox v3.2
 
+## v3.2.1 (2022-04-14)
+
+### Enhancements
+
+* [#5479](https://github.com/netbox-community/netbox/issues/5479) - Allow custom job timeouts for scripts & reports
+* [#8543](https://github.com/netbox-community/netbox/issues/8543) - Improve filtering for wireless LAN VLAN selection
+* [#8920](https://github.com/netbox-community/netbox/issues/8920) - Limit number of non-racked devices displayed
+* [#8956](https://github.com/netbox-community/netbox/issues/8956) - Retain old script/report results for configured lifetime
+* [#8973](https://github.com/netbox-community/netbox/issues/8973) - Display VLAN group count under site view
+* [#9081](https://github.com/netbox-community/netbox/issues/9081) - Add `fhrpgroup_id` filter for IP addresses
+* [#9099](https://github.com/netbox-community/netbox/issues/9099) - Enable display of installed module serial & asset tag in module bays list
+* [#9110](https://github.com/netbox-community/netbox/issues/9110) - Add Neutrik proprietary power connectors
+* [#9123](https://github.com/netbox-community/netbox/issues/9123) - Improve appearance of SSO login providers
+
+### Bug Fixes
+
+* [#8931](https://github.com/netbox-community/netbox/issues/8931) - Copy assigned tenant when cloning a location
+* [#9055](https://github.com/netbox-community/netbox/issues/9055) - Restore ability to move inventory item to other device
+* [#9057](https://github.com/netbox-community/netbox/issues/9057) - Fix missing instance counts for module types
+* [#9061](https://github.com/netbox-community/netbox/issues/9061) - Fix general search for device components
+* [#9065](https://github.com/netbox-community/netbox/issues/9065) - Min/max VID should not be required when filtering VLAN groups
+* [#9079](https://github.com/netbox-community/netbox/issues/9079) - Fail validation when an inventory item is assigned as its own parent
+* [#9096](https://github.com/netbox-community/netbox/issues/9096) - Remove duplicate filter tag when filtering by "none"
+* [#9100](https://github.com/netbox-community/netbox/issues/9100) - Include position field in module type YAML export
+* [#9116](https://github.com/netbox-community/netbox/issues/9116) - `assigned_to_interface` filter for IP addresses should not match FHRP group assignments
+* [#9118](https://github.com/netbox-community/netbox/issues/9118) - Fix validation error when importing VM child interfaces
+* [#9128](https://github.com/netbox-community/netbox/issues/9128) - Resolve component labels per module bay position when installing modules
+
+---
+
 ## v3.2.0 (2022-04-05)
 
 !!! warning "Python 3.8 or Later Required"

+ 16 - 0
netbox/dcim/choices.py

@@ -345,6 +345,10 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_DC = 'dc-terminal'
     # Proprietary
     TYPE_SAF_D_GRID = 'saf-d-grid'
+    TYPE_NEUTRIK_POWERCON_20A = 'neutrik-powercon-20'
+    TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32'
+    TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
+    TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
     # Other
     TYPE_HARDWIRED = 'hardwired'
 
@@ -456,6 +460,10 @@ class PowerPortTypeChoices(ChoiceSet):
         )),
         ('Proprietary', (
             (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
+            (TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'),
+            (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
+            (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
+            (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
         )),
         ('Other', (
             (TYPE_HARDWIRED, 'Hardwired'),
@@ -561,6 +569,10 @@ class PowerOutletTypeChoices(ChoiceSet):
     # Proprietary
     TYPE_HDOT_CX = 'hdot-cx'
     TYPE_SAF_D_GRID = 'saf-d-grid'
+    TYPE_NEUTRIK_POWERCON_20A = 'neutrik-powercon-20a'
+    TYPE_NEUTRIK_POWERCON_32A = 'neutrik-powercon-32a'
+    TYPE_NEUTRIK_POWERCON_TRUE1 = 'neutrik-powercon-true1'
+    TYPE_NEUTRIK_POWERCON_TRUE1_TOP = 'neutrik-powercon-true1-top'
     # Other
     TYPE_HARDWIRED = 'hardwired'
 
@@ -665,6 +677,10 @@ class PowerOutletTypeChoices(ChoiceSet):
         ('Proprietary', (
             (TYPE_HDOT_CX, 'HDOT Cx'),
             (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
+            (TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'),
+            (TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
+            (TYPE_NEUTRIK_POWERCON_TRUE1, 'Neutrik powerCON TRUE1'),
+            (TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
         )),
         ('Other', (
             (TYPE_HARDWIRED, 'Hardwired'),

+ 10 - 10
netbox/dcim/filtersets.py

@@ -1095,8 +1095,8 @@ class PathEndpointFilterSet(django_filters.FilterSet):
 
 
 class ConsolePortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
 ):
@@ -1111,8 +1111,8 @@ class ConsolePortFilterSet(
 
 
 class ConsoleServerPortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
 ):
@@ -1127,8 +1127,8 @@ class ConsoleServerPortFilterSet(
 
 
 class PowerPortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
 ):
@@ -1143,8 +1143,8 @@ class PowerPortFilterSet(
 
 
 class PowerOutletFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
 ):
@@ -1163,8 +1163,8 @@ class PowerOutletFilterSet(
 
 
 class InterfaceFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
 ):
@@ -1291,8 +1291,8 @@ class InterfaceFilterSet(
 
 
 class FrontPortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet
 ):
     type = django_filters.MultipleChoiceFilter(
@@ -1306,8 +1306,8 @@ class FrontPortFilterSet(
 
 
 class RearPortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet
 ):
     type = django_filters.MultipleChoiceFilter(
@@ -1320,21 +1320,21 @@ class RearPortFilterSet(
         fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
 
 
-class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
+class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 
     class Meta:
         model = ModuleBay
         fields = ['id', 'name', 'label', 'description']
 
 
-class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
+class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 
     class Meta:
         model = DeviceBay
         fields = ['id', 'name', 'label', 'description']
 
 
-class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
+class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=InventoryItem.objects.all(),
         label='Parent inventory item (ID)',

+ 5 - 1
netbox/dcim/forms/bulk_edit.py

@@ -1204,6 +1204,10 @@ class InventoryItemBulkEditForm(
     form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']),
     NetBoxModelBulkEditForm
 ):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False
+    )
     role = DynamicModelChoiceField(
         queryset=InventoryItemRole.objects.all(),
         required=False
@@ -1215,7 +1219,7 @@ class InventoryItemBulkEditForm(
 
     model = InventoryItem
     fieldsets = (
-        (None, ('label', 'role', 'manufacturer', 'part_id', 'description')),
+        (None, ('device', 'label', 'role', 'manufacturer', 'part_id', 'description')),
     )
     nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description')
 

+ 5 - 5
netbox/dcim/forms/bulk_import.py

@@ -651,11 +651,11 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
         super().__init__(data, *args, **kwargs)
 
         if data:
-            # Limit interface choices for parent, bridge and lag to device only
-            params = {}
-            if data.get('device'):
-                params[f"device__{self.fields['device'].to_field_name}"] = data.get('device')
-            if params:
+            # Limit choices for parent, bridge, and LAG interfaces to the assigned device
+            if device := data.get('device'):
+                params = {
+                    f"device__{self.fields['device'].to_field_name}": device
+                }
                 self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
                 self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
                 self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)

+ 3 - 3
netbox/dcim/forms/models.py

@@ -1362,6 +1362,9 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
 
 
 class InventoryItemForm(NetBoxModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all()
+    )
     parent = DynamicModelChoiceField(
         queryset=InventoryItem.objects.all(),
         required=False,
@@ -1399,9 +1402,6 @@ class InventoryItemForm(NetBoxModelForm):
             'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
             'description', 'component_type', 'component_id', 'tags',
         ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
 
 
 #

+ 6 - 1
netbox/dcim/forms/object_create.py

@@ -1,7 +1,6 @@
 from django import forms
 
 from dcim.models import *
-from extras.models import Tag
 from netbox.forms import NetBoxModelForm
 from utilities.forms import (
     BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
@@ -12,6 +11,7 @@ __all__ = (
     'DeviceComponentCreateForm',
     'FrontPortCreateForm',
     'FrontPortTemplateCreateForm',
+    'InventoryItemCreateForm',
     'ModularComponentTemplateCreateForm',
     'ModuleBayCreateForm',
     'ModuleBayTemplateCreateForm',
@@ -199,6 +199,11 @@ class ModuleBayCreateForm(DeviceComponentCreateForm):
     field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern')
 
 
+class InventoryItemCreateForm(ComponentCreateForm):
+    # Device is assigned by the model form
+    field_order = ('name_pattern', 'label_pattern')
+
+
 class VirtualChassisCreateForm(NetBoxModelForm):
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),

+ 12 - 7
netbox/dcim/models/device_component_templates.py

@@ -124,6 +124,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
             return self.name.replace('{module}', module.module_bay.position)
         return self.name
 
+    def resolve_label(self, module):
+        if module:
+            return self.label.replace('{module}', module.module_bay.position)
+        return self.label
+
 
 class ConsolePortTemplate(ModularComponentTemplateModel):
     """
@@ -147,7 +152,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             **kwargs
         )
@@ -175,7 +180,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             **kwargs
         )
@@ -215,7 +220,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             maximum_draw=self.maximum_draw,
             allocated_draw=self.allocated_draw,
@@ -286,7 +291,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
             power_port = None
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             power_port=power_port,
             feed_leg=self.feed_leg,
@@ -326,7 +331,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             mgmt_only=self.mgmt_only,
             **kwargs
@@ -397,7 +402,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             rear_port = None
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             color=self.color,
             rear_port=rear_port,
@@ -437,7 +442,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             color=self.color,
             positions=self.positions,

+ 9 - 0
netbox/dcim/models/device_components.py

@@ -1070,3 +1070,12 @@ class InventoryItem(MPTTModel, ComponentModel):
 
     def get_absolute_url(self):
         return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
+
+    def clean(self):
+        super().clean()
+
+        # An InventoryItem cannot be its own parent
+        if self.pk and self.parent_id == self.pk:
+            raise ValidationError({
+                "parent": "Cannot assign self as parent."
+            })

+ 1 - 0
netbox/dcim/models/devices.py

@@ -257,6 +257,7 @@ class DeviceType(NetBoxModel):
                 {
                     'name': c.name,
                     'label': c.label,
+                    'position': c.position,
                     'description': c.description,
                 }
                 for c in self.modulebaytemplates.all()

+ 1 - 1
netbox/dcim/models/sites.py

@@ -367,7 +367,7 @@ class Location(NestedGroupModel):
         to='extras.ImageAttachment'
     )
 
-    clone_fields = ['site', 'parent', 'description']
+    clone_fields = ['site', 'parent', 'tenant', 'description']
 
     class Meta:
         ordering = ['site', 'name']

+ 14 - 2
netbox/dcim/tables/devices.py

@@ -739,13 +739,22 @@ class ModuleBayTable(DeviceComponentTable):
         linkify=True,
         verbose_name='Installed module'
     )
+    module_serial = tables.Column(
+        accessor=tables.A('installed_module__serial')
+    )
+    module_asset_tag = tables.Column(
+        accessor=tables.A('installed_module__asset_tag')
+    )
     tags = columns.TagColumn(
         url_name='dcim:modulebay_list'
     )
 
     class Meta(DeviceComponentTable.Meta):
         model = ModuleBay
-        fields = ('pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'description', 'tags')
+        fields = (
+            'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
+            'description', 'tags',
+        )
         default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description')
 
 
@@ -756,7 +765,10 @@ class DeviceModuleBayTable(ModuleBayTable):
 
     class Meta(DeviceComponentTable.Meta):
         model = ModuleBay
-        fields = ('pk', 'id', 'name', 'label', 'position', 'installed_module', 'description', 'tags', 'actions')
+        fields = (
+            'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
+            'description', 'tags', 'actions',
+        )
         default_columns = ('pk', 'name', 'label', 'installed_module', 'description')
 
 

+ 15 - 7
netbox/dcim/views.py

@@ -14,7 +14,7 @@ from django.views.generic import View
 
 from circuits.models import Circuit
 from extras.views import ObjectConfigContextView
-from ipam.models import ASN, IPAddress, Prefix, Service, VLAN
+from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
@@ -320,6 +320,10 @@ class SiteView(generic.ObjectView):
             'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(),
+            'vlangroup_count': VLANGroup.objects.restrict(request.user, 'view').filter(
+                scope_type=ContentType.objects.get_for_model(Site),
+                scope_id=instance.pk
+            ).count(),
             'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
             'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
@@ -338,6 +342,7 @@ class SiteView(generic.ObjectView):
             'device_count',
             cumulative=True
         ).restrict(request.user, 'view').filter(site=instance)
+
         nonracked_devices = Device.objects.filter(
             site=instance,
             position__isnull=True,
@@ -353,7 +358,8 @@ class SiteView(generic.ObjectView):
             'stats': stats,
             'locations': locations,
             'asns': asns,
-            'nonracked_devices': nonracked_devices,
+            'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
+            'total_nonracked_devices_count': nonracked_devices.count(),
         }
 
 
@@ -431,6 +437,7 @@ class LocationView(generic.ObjectView):
         ).filter(pk__in=location_ids).exclude(pk=instance.pk)
         child_locations_table = tables.LocationTable(child_locations)
         child_locations_table.configure(request)
+
         nonracked_devices = Device.objects.filter(
             location=instance,
             position__isnull=True,
@@ -441,7 +448,8 @@ class LocationView(generic.ObjectView):
             'rack_count': rack_count,
             'device_count': device_count,
             'child_locations_table': child_locations_table,
-            'nonracked_devices': nonracked_devices,
+            'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
+            'total_nonracked_devices_count': nonracked_devices.count(),
         }
 
 
@@ -960,7 +968,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
 
 class ModuleTypeListView(generic.ObjectListView):
     queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
-        # instance_count=count_related(Module, 'module_type')
+        instance_count=count_related(Module, 'module_type')
     )
     filterset = filtersets.ModuleTypeFilterSet
     filterset_form = forms.ModuleTypeFilterForm
@@ -1066,7 +1074,7 @@ class ModuleTypeImportView(generic.ObjectImportView):
 
 class ModuleTypeBulkEditView(generic.BulkEditView):
     queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
-        # instance_count=count_related(Module, 'module_type')
+        instance_count=count_related(Module, 'module_type')
     )
     filterset = filtersets.ModuleTypeFilterSet
     table = tables.ModuleTypeTable
@@ -1075,7 +1083,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
 
 class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
-        # instance_count=count_related(Module, 'module_type')
+        instance_count=count_related(Module, 'module_type')
     )
     filterset = filtersets.ModuleTypeFilterSet
     table = tables.ModuleTypeTable
@@ -2513,7 +2521,7 @@ class InventoryItemEditView(generic.ObjectEditView):
 
 class InventoryItemCreateView(generic.ComponentCreateView):
     queryset = InventoryItem.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.InventoryItemCreateForm
     model_form = forms.InventoryItemForm
     template_name = 'dcim/inventoryitem_create.html'
 

+ 1 - 1
netbox/extras/admin.py

@@ -40,7 +40,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
             'fields': ('DEFAULT_USER_PREFERENCES',),
         }),
         ('Miscellaneous', {
-            'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'),
+            'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOBRESULT_RETENTION', 'MAPS_URL'),
         }),
         ('Config Revision', {
             'fields': ('comment',),

+ 6 - 4
netbox/extras/api/views.py

@@ -179,7 +179,7 @@ class ReportViewSet(ViewSet):
             for r in JobResult.objects.filter(
                 obj_type=report_content_type,
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-            ).defer('data')
+            ).order_by('name', '-created').distinct('name').defer('data')
         }
 
         # Iterate through all available Reports.
@@ -236,7 +236,8 @@ class ReportViewSet(ViewSet):
             run_report,
             report.full_name,
             report_content_type,
-            request.user
+            request.user,
+            job_timeout=report.job_timeout
         )
         report.result = job_result
 
@@ -270,7 +271,7 @@ class ScriptViewSet(ViewSet):
             for r in JobResult.objects.filter(
                 obj_type=script_content_type,
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-            ).defer('data').order_by('created')
+            ).order_by('name', '-created').distinct('name').defer('data')
         }
 
         flat_list = []
@@ -320,7 +321,8 @@ class ScriptViewSet(ViewSet):
                 request.user,
                 data=data,
                 request=copy_safe_request(request),
-                commit=commit
+                commit=commit,
+                job_timeout=script.job_timeout,
             )
             script.result = job_result
             serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

+ 28 - 0
netbox/extras/management/commands/housekeeping.py

@@ -9,6 +9,7 @@ from django.db import DEFAULT_DB_ALIAS
 from django.utils import timezone
 from packaging import version
 
+from extras.models import JobResult
 from extras.models import ObjectChange
 from netbox.config import Config
 
@@ -63,6 +64,33 @@ class Command(BaseCommand):
                 f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
             )
 
+        # Delete expired JobResults
+        if options['verbosity']:
+            self.stdout.write("[*] Checking for expired jobresult records")
+        if config.JOBRESULT_RETENTION:
+            cutoff = timezone.now() - timedelta(days=config.JOBRESULT_RETENTION)
+            if options['verbosity'] >= 2:
+                self.stdout.write(f"\tRetention period: {config.JOBRESULT_RETENTION} days")
+                self.stdout.write(f"\tCut-off time: {cutoff}")
+            expired_records = JobResult.objects.filter(created__lt=cutoff).count()
+            if expired_records:
+                if options['verbosity']:
+                    self.stdout.write(
+                        f"\tDeleting {expired_records} expired records... ",
+                        self.style.WARNING,
+                        ending=""
+                    )
+                    self.stdout.flush()
+                JobResult.objects.filter(created__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
+                if options['verbosity']:
+                    self.stdout.write("Done.", self.style.SUCCESS)
+            elif options['verbosity']:
+                self.stdout.write("\tNo expired records found.", self.style.SUCCESS)
+        elif options['verbosity']:
+            self.stdout.write(
+                f"\tSkipping: No retention period specified (JOBRESULT_RETENTION = {config.JOBRESULT_RETENTION})"
+            )
+
         # Check for new releases (if enabled)
         if options['verbosity']:
             self.stdout.write("[*] Checking for latest release")

+ 2 - 1
netbox/extras/management/commands/runreport.py

@@ -35,7 +35,8 @@ class Command(BaseCommand):
                         run_report,
                         report.full_name,
                         report_content_type,
-                        None
+                        None,
+                        job_timeout=report.job_timeout
                     )
 
                     # Wait on the job to finish

+ 0 - 7
netbox/extras/management/commands/runscript.py

@@ -113,13 +113,6 @@ class Command(BaseCommand):
 
         script_content_type = ContentType.objects.get(app_label='extras', model='script')
 
-        # Delete any previous terminal state results
-        JobResult.objects.filter(
-            obj_type=script_content_type,
-            name=script.full_name,
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-        ).delete()
-
         # Create the job result
         job_result = JobResult.objects.create(
             name=script.full_name,

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

@@ -13,6 +13,7 @@ from django.urls import reverse
 from django.utils import timezone
 from django.utils.formats import date_format
 from rest_framework.utils.encoders import JSONEncoder
+import django_rq
 
 from extras.choices import *
 from extras.constants import *
@@ -550,7 +551,8 @@ class JobResult(models.Model):
             job_id=uuid.uuid4()
         )
 
-        func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
+        queue = django_rq.get_queue("default")
+        queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
 
         return job_result
 

+ 1 - 9
netbox/extras/reports.py

@@ -84,15 +84,6 @@ def run_report(job_result, *args, **kwargs):
         job_result.save()
         logging.error(f"Error during execution of report {job_result.name}")
 
-    # Delete any previous terminal state results
-    JobResult.objects.filter(
-        obj_type=job_result.obj_type,
-        name=job_result.name,
-        status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-    ).exclude(
-        pk=job_result.pk
-    ).delete()
-
 
 class Report(object):
     """
@@ -119,6 +110,7 @@ class Report(object):
     }
     """
     description = None
+    job_timeout = None
 
     def __init__(self):
 

+ 5 - 11
netbox/extras/scripts.py

@@ -298,6 +298,10 @@ class BaseScript:
     def module(cls):
         return cls.__module__
 
+    @classproperty
+    def job_timeout(self):
+        return getattr(self.Meta, 'job_timeout', None)
+
     @classmethod
     def _get_vars(cls):
         vars = {}
@@ -414,7 +418,6 @@ def is_variable(obj):
     return isinstance(obj, ScriptVariable)
 
 
-@job('default')
 def run_script(data, request, commit=True, *args, **kwargs):
     """
     A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
@@ -478,15 +481,6 @@ def run_script(data, request, commit=True, *args, **kwargs):
     else:
         _run_script()
 
-    # Delete any previous terminal state results
-    JobResult.objects.filter(
-        obj_type=job_result.obj_type,
-        name=job_result.name,
-        status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-    ).exclude(
-        pk=job_result.pk
-    ).delete()
-
 
 def get_scripts(use_names=False):
     """
@@ -494,7 +488,7 @@ def get_scripts(use_names=False):
     defined name in place of the actual module name.
     """
     scripts = OrderedDict()
-    # Iterate through all modules within the reports path. These are the user-created files in which reports are
+    # Iterate through all modules within the scripts path. These are the user-created files in which reports are
     # defined.
     for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
         # Remove cached module to ensure consistency with filesystem

+ 7 - 4
netbox/extras/views.py

@@ -524,7 +524,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
             for r in JobResult.objects.filter(
                 obj_type=report_content_type,
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-            ).defer('data')
+            ).order_by('name', '-created').distinct('name').defer('data')
         }
 
         ret = []
@@ -588,7 +588,8 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
             run_report,
             report.full_name,
             report_content_type,
-            request.user
+            request.user,
+            job_timeout=report.job_timeout
         )
 
         return redirect('extras:report_result', job_result_pk=job_result.pk)
@@ -655,7 +656,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
             for r in JobResult.objects.filter(
                 obj_type=script_content_type,
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-            ).defer('data')
+            ).order_by('name', '-created').distinct('name').defer('data')
         }
 
         for _scripts in scripts.values():
@@ -708,6 +709,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
             commit = form.cleaned_data.pop('_commit')
 
             script_content_type = ContentType.objects.get(app_label='extras', model='script')
+
             job_result = JobResult.enqueue_job(
                 run_script,
                 script.full_name,
@@ -715,7 +717,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
                 request.user,
                 data=form.cleaned_data,
                 request=copy_safe_request(request),
-                commit=commit
+                commit=commit,
+                job_timeout=script.job_timeout,
             )
 
             return redirect('extras:script_result', job_result_pk=job_result.pk)

+ 16 - 1
netbox/ipam/filtersets.py

@@ -535,6 +535,11 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         queryset=VMInterface.objects.all(),
         label='VM interface (ID)',
     )
+    fhrpgroup_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='fhrpgroup',
+        queryset=FHRPGroup.objects.all(),
+        label='FHRP group (ID)',
+    )
     assigned_to_interface = django_filters.BooleanFilter(
         method='_assigned_to_interface',
         label='Is assigned to an interface',
@@ -613,7 +618,17 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         )
 
     def _assigned_to_interface(self, queryset, name, value):
-        return queryset.exclude(assigned_object_id__isnull=value)
+        content_types = ContentType.objects.get_for_models(Interface, VMInterface).values()
+        if value:
+            return queryset.filter(
+                assigned_object_type__in=content_types,
+                assigned_object_id__isnull=False
+            )
+        else:
+            return queryset.exclude(
+                assigned_object_type__in=content_types,
+                assigned_object_id__isnull=False
+            )
 
 
 class FHRPGroupFilterSet(NetBoxModelFilterSet):

+ 4 - 0
netbox/ipam/forms/filtersets.py

@@ -377,12 +377,16 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
         label=_('Rack')
     )
     min_vid = forms.IntegerField(
+        required=False,
         min_value=VLAN_VID_MIN,
         max_value=VLAN_VID_MAX,
+        label='Minimum VID'
     )
     max_vid = forms.IntegerField(
+        required=False,
         min_value=VLAN_VID_MIN,
         max_value=VLAN_VID_MAX,
+        label='Maximum VID'
     )
     tag = TagFilterField(model)
 

+ 2 - 1
netbox/ipam/models/__init__.py

@@ -1,8 +1,9 @@
+# Ensure that VRFs are imported before IPs/prefixes so dumpdata & loaddata work correctly
 from .fhrp import *
+from .vrfs import *
 from .ip import *
 from .services import *
 from .vlans import *
-from .vrfs import *
 
 __all__ = (
     'ASN',

+ 20 - 5
netbox/ipam/tests/test_filtersets.py

@@ -771,6 +771,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         VMInterface.objects.bulk_create(vminterfaces)
 
+        fhrp_groups = (
+            FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=101),
+            FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=102),
+        )
+        FHRPGroup.objects.bulk_create(fhrp_groups)
+
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -791,18 +797,22 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
             IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
             IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
             IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
+            IPAddress(address='10.0.0.5/24', tenant=None, vrf=None, assigned_object=fhrp_groups[0], status=IPAddressStatusChoices.STATUS_ACTIVE),
             IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
             IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a', description='foobar2'),
             IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
             IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
             IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
+            IPAddress(address='2001:db8::5/64', tenant=None, vrf=None, assigned_object=fhrp_groups[1], status=IPAddressStatusChoices.STATUS_ACTIVE),
             IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
         )
         IPAddress.objects.bulk_create(ipaddresses)
 
     def test_family(self):
+        params = {'family': '4'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'family': '6'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
     def test_dns_name(self):
         params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
@@ -814,9 +824,9 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
 
     def test_parent(self):
         params = {'parent': '10.0.0.0/24'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'parent': '2001:db8::/64'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
     def test_filter_address(self):
         # Check IPv4 and IPv6, with and without a mask
@@ -835,7 +845,7 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
 
     def test_mask_length(self):
         params = {'mask_length': '24'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
 
     def test_vrf(self):
         vrfs = VRF.objects.all()[:2]
@@ -872,11 +882,16 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'vminterface': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_fhrpgroup(self):
+        fhrp_groups = FHRPGroup.objects.all()[:2]
+        params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_assigned_to_interface(self):
         params = {'assigned_to_interface': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'assigned_to_interface': 'false'}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
     def test_status(self):
         params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]}

+ 38 - 1
netbox/netbox/authentication.py

@@ -13,8 +13,45 @@ from utilities.permissions import permission_is_exempt, resolve_permission, reso
 
 UserModel = get_user_model()
 
+AUTH_BACKEND_ATTRS = {
+    # backend name: title, MDI icon name
+    'amazon': ('Amazon AWS', 'aws'),
+    'apple': ('Apple', 'apple'),
+    'auth0': ('Auth0', None),
+    'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'),
+    'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'),
+    'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
+    'bitbucket': ('BitBucket', 'bitbucket'),
+    'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
+    'digitalocean': ('DigitalOcean', 'digital-ocean'),
+    'docker': ('Docker', 'docker'),
+    'github': ('GitHub', 'docker'),
+    'github-app': ('GitHub', 'github'),
+    'github-org': ('GitHub', 'github'),
+    'github-team': ('GitHub', 'github'),
+    'github-enterprise': ('GitHub Enterprise', 'github'),
+    'github-enterprise-org': ('GitHub Enterprise', 'github'),
+    'github-enterprise-team': ('GitHub Enterprise', 'github'),
+    'gitlab': ('GitLab', 'gitlab'),
+    'google-oauth2': ('Google', 'google'),
+    'google-openidconnect': ('Google', 'google'),
+    'hubspot': ('HubSpot', 'hubspot'),
+    'keycloak': ('Keycloak', None),
+    'microsoft-graph': ('Microsoft Graph', 'microsoft'),
+    'okta': ('Okta', None),
+    'salesforce-oauth2': ('Salesforce', 'salesforce'),
+}
+
+
+def get_auth_backend_display(name):
+    """
+    Return the user-friendly name and icon name for a remote authentication backend, if known. Defaults to the
+    raw backend name and no icon.
+    """
+    return AUTH_BACKEND_ATTRS.get(name, (name, None))
+
 
-class ObjectPermissionMixin():
+class ObjectPermissionMixin:
 
     def get_all_permissions(self, user_obj, obj=None):
         if not user_obj.is_active or user_obj.is_anonymous:

+ 7 - 0
netbox/netbox/config/parameters.py

@@ -187,6 +187,13 @@ PARAMS = (
         description="Days to retain changelog history (set to zero for unlimited)",
         field=forms.IntegerField
     ),
+    ConfigParam(
+        name='JOBRESULT_RETENTION',
+        label='Job result retention',
+        default=90,
+        description="Days to retain job result history (set to zero for unlimited)",
+        field=forms.IntegerField
+    ),
     ConfigParam(
         name='MAPS_URL',
         label='Maps URL',

+ 1 - 1
netbox/netbox/settings.py

@@ -26,7 +26,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.2.0'
+VERSION = '3.2.1'
 
 # Hostname
 HOSTNAME = platform.node()

+ 1 - 10
netbox/netbox/views/__init__.py

@@ -19,8 +19,7 @@ from circuits.models import Circuit, Provider
 from dcim.models import (
     Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site,
 )
-from extras.choices import JobResultStatusChoices
-from extras.models import ObjectChange, JobResult
+from extras.models import ObjectChange
 from extras.tables import ObjectChangeTable
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
@@ -48,13 +47,6 @@ class HomeView(View):
             pk__lt=F('_path__destination_id')
         )
 
-        # Report Results
-        report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        report_results = JobResult.objects.filter(
-            obj_type=report_content_type,
-            status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-        ).defer('data')[:10]
-
         def build_stats():
             org = (
                 ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
@@ -150,7 +142,6 @@ class HomeView(View):
         return render(request, self.template_name, {
             'search_form': SearchForm(),
             'stats': build_stats(),
-            'report_results': report_results,
             'changelog_table': changelog_table,
             'new_release': new_release,
         })

+ 41 - 27
netbox/templates/dcim/inc/nonracked_devices.html

@@ -1,40 +1,54 @@
 {% load helpers %}
 
 <div class="card">
-<h5 class="card-header">
-    Non-Racked Devices
-</h5>
-<div class="card-body">
-{% if nonracked_devices %}
-    <table class="table table-hover">
-        <tr>
-            <th>Name</th>
-            <th>Role</th>
-            <th>Type</th>
-            <th colspan="2">Parent Device</th>
-        </tr>
-        {% for device in nonracked_devices %}
-        <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
-            <td>
-                <a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
-            </td>
-            <td>{{ device.device_role }}</td>
-            <td>{{ device.device_type }}</td>
-            {% if device.parent_bay %}
-                <td>{{ device.parent_bay.device|linkify }}</td>
-                <td>{{ device.parent_bay }}</td>
-            {% else %}
-                <td colspan="2" class="text-muted">&mdash;</td>
+    <h5 class="card-header">
+        Non-Racked Devices
+    </h5>
+    <div class="card-body">
+    {% if nonracked_devices %}
+        <table class="table table-hover">
+            <tr>
+                <th>Name</th>
+                <th>Role</th>
+                <th>Type</th>
+                <th colspan="2">Parent Device</th>
+            </tr>
+            {% for device in nonracked_devices %}
+            <tr{% if device.device_type.u_height %} class="warning"{% endif %}>
+                <td>
+                    <a href="{% url 'dcim:device' pk=device.pk %}">{{ device }}</a>
+                </td>
+                <td>{{ device.device_role }}</td>
+                <td>{{ device.device_type }}</td>
+                {% if device.parent_bay %}
+                    <td>{{ device.parent_bay.device|linkify }}</td>
+                    <td>{{ device.parent_bay }}</td>
+                {% else %}
+                    <td colspan="2" class="text-muted">&mdash;</td>
+                {% endif %}
+            </tr>
+            {% endfor %}
+        </table>
+
+        {%  if total_nonracked_devices_count > nonracked_devices.count %}
+            {% if object|meta:'verbose_name' == 'site' %}
+                <div class="text-muted">
+                    Displaying {{ nonracked_devices.count }} of {{ total_nonracked_devices_count }} devices (<a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}&rack_id=null">View full list</a>)
+                </div>
+            {% elif object|meta:'verbose_name' == 'location' %}
+                <div class="text-muted">
+                    Displaying {{ nonracked_devices.count }} of {{ total_nonracked_devices_count }} devices (<a href="{% url 'dcim:device_list' %}?location_id={{ object.pk }}&rack_id=null">View full list</a>)
+                </div>
             {% endif %}
-        </tr>
-        {% endfor %}
-    </table>
+        {% endif %}
+
     {% else %}
         <div class="text-muted">
             None
         </div>
     {% endif %}
     </div>
+
     {% if perms.dcim.add_device %}
         {% if object|meta:'verbose_name' == 'rack' %}
         <div class="card-footer text-end noprint">

+ 12 - 0
netbox/templates/dcim/modulebay.html

@@ -52,6 +52,10 @@
         {% if object.installed_module %}
           {% with module=object.installed_module %}
             <table class="table table-hover attr-table">
+              <tr>
+                <th scope="row">Module</th>
+                <td>{{ module|linkify }}</td>
+              </tr>
               <tr>
                 <th scope="row">Manufacturer</th>
                 <td>{{ module.module_type.manufacturer|linkify }}</td>
@@ -60,6 +64,14 @@
                 <th scope="row">Module Type</th>
                 <td>{{ module.module_type|linkify }}</td>
               </tr>
+              <tr>
+                <th scope="row">Serial Number</th>
+                <td class="font-monospace">{{ module.serial|placeholder }}</td>
+              </tr>
+              <tr>
+                <th scope="row">Asset Tag</th>
+                <td class="font-monospace">{{ module.asset_tag|placeholder }}</td>
+              </tr>
             </table>
           {% endwith %}
         {% else %}

+ 10 - 0
netbox/templates/dcim/site.html

@@ -188,6 +188,16 @@
                 {% endif %}
               </td>
             </tr>
+            <tr>
+              <th scope="row">VLAN Groups</th>
+              <td class="text-end">
+                {% if stats.vlangroup_count %}
+                  <a href="{% url 'ipam:vlangroup_list' %}?site={{ object.pk }}">{{ stats.vlangroup_count }}</a>
+                {% else %}
+                  {{ ''|placeholder }}
+                {% endif %}
+              </td>
+            </tr>
             <tr>
               <th scope="row">VLANs</th>
               <td class="text-end">

+ 6 - 4
netbox/templates/login.html

@@ -39,11 +39,13 @@
       </form>
     </div>
 
-    {# TODO: Improve the design & layout #}
     {% if auth_backends %}
-      <h6 class="mt-4">Or use an SSO provider:</h6>
-      {% for name, backend in auth_backends.items %}
-        <h4><a href="{% url 'social:begin' backend=name %}" class="my-2">{{ name }}</a></h4>
+      <h6 class="mt-4 mb-3">Or use a single sign-on (SSO) provider:</h6>
+      {% for name, display in auth_backends.items %}
+        <h5>
+          {% if display.1 %}<i class="mdi mdi-{{ display.1 }}"></i>{% endif %}
+          <a href="{% url 'social:begin' backend=name %}" class="my-2">{{ display.0 }}</a>
+        </h5>
       {% endfor %}
     {% endif %}
 

+ 6 - 1
netbox/users/views.py

@@ -16,6 +16,7 @@ from social_core.backends.utils import load_backends
 
 from extras.models import ObjectChange
 from extras.tables import ObjectChangeTable
+from netbox.authentication import get_auth_backend_display
 from netbox.config import get_config
 from utilities.forms import ConfirmationForm
 from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
@@ -43,9 +44,13 @@ class LoginView(View):
             logger = logging.getLogger('netbox.auth.login')
             return self.redirect_to_next(request, logger)
 
+        auth_backends = {
+            name: get_auth_backend_display(name) for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys()
+        }
+
         return render(request, self.template_name, {
             'form': form,
-            'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS),
+            'auth_backends': auth_backends,
         })
 
     def post(self, request):

+ 5 - 5
netbox/utilities/forms/utils.py

@@ -144,11 +144,11 @@ def get_selected_values(form, field_name):
             label for value, label in choices if str(value) in filter_data or None in filter_data
         ]
 
-    if hasattr(field, 'null_option'):
-        # If the field has a `null_option` attribute set and it is selected,
-        # add it to the field's grouped choices.
-        if field.null_option is not None and None in filter_data:
-            values.append(field.null_option)
+    # If the field has a `null_option` attribute set and it is selected,
+    # add it to the field's grouped choices.
+    if getattr(field, 'null_option', None) and None in filter_data:
+        values.remove(None)
+        values.insert(0, field.null_option)
 
     return values
 

+ 12 - 0
netbox/virtualization/forms/bulk_import.py

@@ -136,6 +136,18 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm):
             'vrf',
         )
 
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+            # Limit interface choices for parent & bridge interfaces to the assigned VM
+            if virtual_machine := data.get('virtual_machine'):
+                params = {
+                    f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": virtual_machine
+                }
+                self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
+                self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
+
     def clean_enabled(self):
         # Make sure enabled is True when it's not included in the uploaded data
         if 'enabled' not in self.data:

+ 47 - 7
netbox/wireless/forms/models.py

@@ -1,8 +1,7 @@
-from dcim.models import Device, Interface, Location, Site
-from extras.models import Tag
-from ipam.models import VLAN
+from dcim.models import Device, Interface, Location, Region, Site, SiteGroup
+from ipam.models import VLAN, VLANGroup
 from netbox.forms import NetBoxModelForm
-from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect
+from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect
 from wireless.models import *
 
 __all__ = (
@@ -31,22 +30,63 @@ class WirelessLANForm(NetBoxModelForm):
         queryset=WirelessLANGroup.objects.all(),
         required=False
     )
+
+    region = DynamicModelChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site_group = DynamicModelChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        initial_params={
+            'sites': '$site'
+        }
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        null_option='None',
+        query_params={
+            'region_id': '$region',
+            'group_id': '$site_group',
+        }
+    )
+    vlan_group = DynamicModelChoiceField(
+        queryset=VLANGroup.objects.all(),
+        required=False,
+        label='VLAN group',
+        null_option='None',
+        query_params={
+            'site': '$site'
+        },
+        initial_params={
+            'vlans': '$vlan'
+        }
+    )
     vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         required=False,
-        label='VLAN'
+        label='VLAN',
+        query_params={
+            'site_id': '$site',
+            'group_id': '$vlan_group',
+        }
     )
 
     fieldsets = (
         ('Wireless LAN', ('ssid', 'group', 'description', 'tags')),
-        ('VLAN', ('vlan',)),
+        ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
     )
 
     class Meta:
         model = WirelessLAN
         fields = [
-            'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags',
+            'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'auth_type',
+            'auth_cipher', 'auth_psk', 'tags',
         ]
         widgets = {
             'auth_type': StaticSelect,

+ 3 - 3
requirements.txt

@@ -1,4 +1,4 @@
-Django==4.0.3
+Django==4.0.4
 django-cors-headers==3.11.0
 django-debug-toolbar==3.2.4
 django-filter==21.1
@@ -18,7 +18,7 @@ gunicorn==20.1.0
 Jinja2==3.0.3
 Markdown==3.3.6
 markdown-include==0.6.0
-mkdocs-material==8.2.8
+mkdocs-material==8.2.9
 mkdocstrings==0.17.0
 netaddr==0.8.0
 Pillow==9.1.0
@@ -27,7 +27,7 @@ PyYAML==6.0
 social-auth-app-django==5.0.0
 social-auth-core==4.2.0
 svgwrite==1.4.2
-tablib==3.2.0
+tablib==3.2.1
 tzdata==2022.1
 
 # Workaround for #7401