Sfoglia il codice sorgente

Merge pull request #9131 from netbox-community/develop

Release v3.2.1
Jeremy Stretch 3 anni fa
parent
commit
7cd9bcd3f5
47 ha cambiato i file con 450 aggiunte e 146 eliminazioni
  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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.2.0
+      placeholder: v3.2.1
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown
@@ -22,9 +22,9 @@ body:
       label: Python version
       label: Python version
       description: What version of Python are you currently running?
       description: What version of Python are you currently running?
       options:
       options:
-        - "3.7"
         - "3.8"
         - "3.8"
         - "3.9"
         - "3.9"
+        - "3.10"
     validations:
     validations:
       required: true
       required: true
   - type: textarea
   - type: textarea

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.2.0
+      placeholder: v3.2.1
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - 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
 * Clearing expired authentication sessions from the database
 * Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention)
 * 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.
 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
 ## 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:
 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`
 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
 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
 ## 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:
 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
 !!! 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.
     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:
 The following methods are available to log results within a report:
 
 
 * log(message)
 * 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.
 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
 !!! 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.
     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
 # 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)
 ## v3.2.0 (2022-04-05)
 
 
 !!! warning "Python 3.8 or Later Required"
 !!! warning "Python 3.8 or Later Required"

+ 16 - 0
netbox/dcim/choices.py

@@ -345,6 +345,10 @@ class PowerPortTypeChoices(ChoiceSet):
     TYPE_DC = 'dc-terminal'
     TYPE_DC = 'dc-terminal'
     # Proprietary
     # Proprietary
     TYPE_SAF_D_GRID = 'saf-d-grid'
     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
     # Other
     TYPE_HARDWIRED = 'hardwired'
     TYPE_HARDWIRED = 'hardwired'
 
 
@@ -456,6 +460,10 @@ class PowerPortTypeChoices(ChoiceSet):
         )),
         )),
         ('Proprietary', (
         ('Proprietary', (
             (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
             (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', (
         ('Other', (
             (TYPE_HARDWIRED, 'Hardwired'),
             (TYPE_HARDWIRED, 'Hardwired'),
@@ -561,6 +569,10 @@ class PowerOutletTypeChoices(ChoiceSet):
     # Proprietary
     # Proprietary
     TYPE_HDOT_CX = 'hdot-cx'
     TYPE_HDOT_CX = 'hdot-cx'
     TYPE_SAF_D_GRID = 'saf-d-grid'
     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
     # Other
     TYPE_HARDWIRED = 'hardwired'
     TYPE_HARDWIRED = 'hardwired'
 
 
@@ -665,6 +677,10 @@ class PowerOutletTypeChoices(ChoiceSet):
         ('Proprietary', (
         ('Proprietary', (
             (TYPE_HDOT_CX, 'HDOT Cx'),
             (TYPE_HDOT_CX, 'HDOT Cx'),
             (TYPE_SAF_D_GRID, 'Saf-D-Grid'),
             (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', (
         ('Other', (
             (TYPE_HARDWIRED, 'Hardwired'),
             (TYPE_HARDWIRED, 'Hardwired'),

+ 10 - 10
netbox/dcim/filtersets.py

@@ -1095,8 +1095,8 @@ class PathEndpointFilterSet(django_filters.FilterSet):
 
 
 
 
 class ConsolePortFilterSet(
 class ConsolePortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
@@ -1111,8 +1111,8 @@ class ConsolePortFilterSet(
 
 
 
 
 class ConsoleServerPortFilterSet(
 class ConsoleServerPortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
@@ -1127,8 +1127,8 @@ class ConsoleServerPortFilterSet(
 
 
 
 
 class PowerPortFilterSet(
 class PowerPortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
@@ -1143,8 +1143,8 @@ class PowerPortFilterSet(
 
 
 
 
 class PowerOutletFilterSet(
 class PowerOutletFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
@@ -1163,8 +1163,8 @@ class PowerOutletFilterSet(
 
 
 
 
 class InterfaceFilterSet(
 class InterfaceFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet,
     CableTerminationFilterSet,
     PathEndpointFilterSet
     PathEndpointFilterSet
 ):
 ):
@@ -1291,8 +1291,8 @@ class InterfaceFilterSet(
 
 
 
 
 class FrontPortFilterSet(
 class FrontPortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet
     CableTerminationFilterSet
 ):
 ):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1306,8 +1306,8 @@ class FrontPortFilterSet(
 
 
 
 
 class RearPortFilterSet(
 class RearPortFilterSet(
-    NetBoxModelFilterSet,
     ModularDeviceComponentFilterSet,
     ModularDeviceComponentFilterSet,
+    NetBoxModelFilterSet,
     CableTerminationFilterSet
     CableTerminationFilterSet
 ):
 ):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
@@ -1320,21 +1320,21 @@ class RearPortFilterSet(
         fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
         fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
 
 
 
 
-class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
+class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = ModuleBay
         model = ModuleBay
         fields = ['id', 'name', 'label', 'description']
         fields = ['id', 'name', 'label', 'description']
 
 
 
 
-class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
+class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
         fields = ['id', 'name', 'label', 'description']
         fields = ['id', 'name', 'label', 'description']
 
 
 
 
-class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
+class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
         label='Parent inventory item (ID)',
         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']),
     form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']),
     NetBoxModelBulkEditForm
     NetBoxModelBulkEditForm
 ):
 ):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False
+    )
     role = DynamicModelChoiceField(
     role = DynamicModelChoiceField(
         queryset=InventoryItemRole.objects.all(),
         queryset=InventoryItemRole.objects.all(),
         required=False
         required=False
@@ -1215,7 +1219,7 @@ class InventoryItemBulkEditForm(
 
 
     model = InventoryItem
     model = InventoryItem
     fieldsets = (
     fieldsets = (
-        (None, ('label', 'role', 'manufacturer', 'part_id', 'description')),
+        (None, ('device', 'label', 'role', 'manufacturer', 'part_id', 'description')),
     )
     )
     nullable_fields = ('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)
         super().__init__(data, *args, **kwargs)
 
 
         if data:
         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['parent'].queryset = self.fields['parent'].queryset.filter(**params)
                 self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
                 self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
                 self.fields['lag'].queryset = self.fields['lag'].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):
 class InventoryItemForm(NetBoxModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all()
+    )
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
         required=False,
         required=False,
@@ -1399,9 +1402,6 @@ class InventoryItemForm(NetBoxModelForm):
             'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
             'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
             'description', 'component_type', 'component_id', 'tags',
             '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 django import forms
 
 
 from dcim.models import *
 from dcim.models import *
-from extras.models import Tag
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
     BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
@@ -12,6 +11,7 @@ __all__ = (
     'DeviceComponentCreateForm',
     'DeviceComponentCreateForm',
     'FrontPortCreateForm',
     'FrontPortCreateForm',
     'FrontPortTemplateCreateForm',
     'FrontPortTemplateCreateForm',
+    'InventoryItemCreateForm',
     'ModularComponentTemplateCreateForm',
     'ModularComponentTemplateCreateForm',
     'ModuleBayCreateForm',
     'ModuleBayCreateForm',
     'ModuleBayTemplateCreateForm',
     'ModuleBayTemplateCreateForm',
@@ -199,6 +199,11 @@ class ModuleBayCreateForm(DeviceComponentCreateForm):
     field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern')
     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):
 class VirtualChassisCreateForm(NetBoxModelForm):
     region = DynamicModelChoiceField(
     region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         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.replace('{module}', module.module_bay.position)
         return self.name
         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):
 class ConsolePortTemplate(ModularComponentTemplateModel):
     """
     """
@@ -147,7 +152,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             **kwargs
             **kwargs
         )
         )
@@ -175,7 +180,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             **kwargs
             **kwargs
         )
         )
@@ -215,7 +220,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             maximum_draw=self.maximum_draw,
             maximum_draw=self.maximum_draw,
             allocated_draw=self.allocated_draw,
             allocated_draw=self.allocated_draw,
@@ -286,7 +291,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
             power_port = None
             power_port = None
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             power_port=power_port,
             power_port=power_port,
             feed_leg=self.feed_leg,
             feed_leg=self.feed_leg,
@@ -326,7 +331,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             mgmt_only=self.mgmt_only,
             mgmt_only=self.mgmt_only,
             **kwargs
             **kwargs
@@ -397,7 +402,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             rear_port = None
             rear_port = None
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             color=self.color,
             color=self.color,
             rear_port=rear_port,
             rear_port=rear_port,
@@ -437,7 +442,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
-            label=self.label,
+            label=self.resolve_label(kwargs.get('module')),
             type=self.type,
             type=self.type,
             color=self.color,
             color=self.color,
             positions=self.positions,
             positions=self.positions,

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

@@ -1070,3 +1070,12 @@ class InventoryItem(MPTTModel, ComponentModel):
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
         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,
                     'name': c.name,
                     'label': c.label,
                     'label': c.label,
+                    'position': c.position,
                     'description': c.description,
                     'description': c.description,
                 }
                 }
                 for c in self.modulebaytemplates.all()
                 for c in self.modulebaytemplates.all()

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

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

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

@@ -739,13 +739,22 @@ class ModuleBayTable(DeviceComponentTable):
         linkify=True,
         linkify=True,
         verbose_name='Installed module'
         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(
     tags = columns.TagColumn(
         url_name='dcim:modulebay_list'
         url_name='dcim:modulebay_list'
     )
     )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ModuleBay
         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')
         default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description')
 
 
 
 
@@ -756,7 +765,10 @@ class DeviceModuleBayTable(ModuleBayTable):
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ModuleBay
         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')
         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 circuits.models import Circuit
 from extras.views import ObjectConfigContextView
 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 ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
 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(),
             'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
             'device_count': Device.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(),
             '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(),
             '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(),
             '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(),
             'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
@@ -338,6 +342,7 @@ class SiteView(generic.ObjectView):
             'device_count',
             'device_count',
             cumulative=True
             cumulative=True
         ).restrict(request.user, 'view').filter(site=instance)
         ).restrict(request.user, 'view').filter(site=instance)
+
         nonracked_devices = Device.objects.filter(
         nonracked_devices = Device.objects.filter(
             site=instance,
             site=instance,
             position__isnull=True,
             position__isnull=True,
@@ -353,7 +358,8 @@ class SiteView(generic.ObjectView):
             'stats': stats,
             'stats': stats,
             'locations': locations,
             'locations': locations,
             'asns': asns,
             '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)
         ).filter(pk__in=location_ids).exclude(pk=instance.pk)
         child_locations_table = tables.LocationTable(child_locations)
         child_locations_table = tables.LocationTable(child_locations)
         child_locations_table.configure(request)
         child_locations_table.configure(request)
+
         nonracked_devices = Device.objects.filter(
         nonracked_devices = Device.objects.filter(
             location=instance,
             location=instance,
             position__isnull=True,
             position__isnull=True,
@@ -441,7 +448,8 @@ class LocationView(generic.ObjectView):
             'rack_count': rack_count,
             'rack_count': rack_count,
             'device_count': device_count,
             'device_count': device_count,
             'child_locations_table': child_locations_table,
             '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):
 class ModuleTypeListView(generic.ObjectListView):
     queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
     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 = filtersets.ModuleTypeFilterSet
     filterset_form = forms.ModuleTypeFilterForm
     filterset_form = forms.ModuleTypeFilterForm
@@ -1066,7 +1074,7 @@ class ModuleTypeImportView(generic.ObjectImportView):
 
 
 class ModuleTypeBulkEditView(generic.BulkEditView):
 class ModuleTypeBulkEditView(generic.BulkEditView):
     queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
     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 = filtersets.ModuleTypeFilterSet
     table = tables.ModuleTypeTable
     table = tables.ModuleTypeTable
@@ -1075,7 +1083,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
 
 
 class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
 class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
     queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
     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 = filtersets.ModuleTypeFilterSet
     table = tables.ModuleTypeTable
     table = tables.ModuleTypeTable
@@ -2513,7 +2521,7 @@ class InventoryItemEditView(generic.ObjectEditView):
 
 
 class InventoryItemCreateView(generic.ComponentCreateView):
 class InventoryItemCreateView(generic.ComponentCreateView):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.InventoryItemCreateForm
     model_form = forms.InventoryItemForm
     model_form = forms.InventoryItemForm
     template_name = 'dcim/inventoryitem_create.html'
     template_name = 'dcim/inventoryitem_create.html'
 
 

+ 1 - 1
netbox/extras/admin.py

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

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

@@ -179,7 +179,7 @@ class ReportViewSet(ViewSet):
             for r in JobResult.objects.filter(
             for r in JobResult.objects.filter(
                 obj_type=report_content_type,
                 obj_type=report_content_type,
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-            ).defer('data')
+            ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
 
 
         # Iterate through all available Reports.
         # Iterate through all available Reports.
@@ -236,7 +236,8 @@ class ReportViewSet(ViewSet):
             run_report,
             run_report,
             report.full_name,
             report.full_name,
             report_content_type,
             report_content_type,
-            request.user
+            request.user,
+            job_timeout=report.job_timeout
         )
         )
         report.result = job_result
         report.result = job_result
 
 
@@ -270,7 +271,7 @@ class ScriptViewSet(ViewSet):
             for r in JobResult.objects.filter(
             for r in JobResult.objects.filter(
                 obj_type=script_content_type,
                 obj_type=script_content_type,
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-            ).defer('data').order_by('created')
+            ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
 
 
         flat_list = []
         flat_list = []
@@ -320,7 +321,8 @@ class ScriptViewSet(ViewSet):
                 request.user,
                 request.user,
                 data=data,
                 data=data,
                 request=copy_safe_request(request),
                 request=copy_safe_request(request),
-                commit=commit
+                commit=commit,
+                job_timeout=script.job_timeout,
             )
             )
             script.result = job_result
             script.result = job_result
             serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
             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 django.utils import timezone
 from packaging import version
 from packaging import version
 
 
+from extras.models import JobResult
 from extras.models import ObjectChange
 from extras.models import ObjectChange
 from netbox.config import Config
 from netbox.config import Config
 
 
@@ -63,6 +64,33 @@ class Command(BaseCommand):
                 f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
                 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)
         # Check for new releases (if enabled)
         if options['verbosity']:
         if options['verbosity']:
             self.stdout.write("[*] Checking for latest release")
             self.stdout.write("[*] Checking for latest release")

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

@@ -35,7 +35,8 @@ class Command(BaseCommand):
                         run_report,
                         run_report,
                         report.full_name,
                         report.full_name,
                         report_content_type,
                         report_content_type,
-                        None
+                        None,
+                        job_timeout=report.job_timeout
                     )
                     )
 
 
                     # Wait on the job to finish
                     # 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')
         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
         # Create the job result
         job_result = JobResult.objects.create(
         job_result = JobResult.objects.create(
             name=script.full_name,
             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 import timezone
 from django.utils.formats import date_format
 from django.utils.formats import date_format
 from rest_framework.utils.encoders import JSONEncoder
 from rest_framework.utils.encoders import JSONEncoder
+import django_rq
 
 
 from extras.choices import *
 from extras.choices import *
 from extras.constants import *
 from extras.constants import *
@@ -550,7 +551,8 @@ class JobResult(models.Model):
             job_id=uuid.uuid4()
             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
         return job_result
 
 

+ 1 - 9
netbox/extras/reports.py

@@ -84,15 +84,6 @@ def run_report(job_result, *args, **kwargs):
         job_result.save()
         job_result.save()
         logging.error(f"Error during execution of report {job_result.name}")
         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):
 class Report(object):
     """
     """
@@ -119,6 +110,7 @@ class Report(object):
     }
     }
     """
     """
     description = None
     description = None
+    job_timeout = None
 
 
     def __init__(self):
     def __init__(self):
 
 

+ 5 - 11
netbox/extras/scripts.py

@@ -298,6 +298,10 @@ class BaseScript:
     def module(cls):
     def module(cls):
         return cls.__module__
         return cls.__module__
 
 
+    @classproperty
+    def job_timeout(self):
+        return getattr(self.Meta, 'job_timeout', None)
+
     @classmethod
     @classmethod
     def _get_vars(cls):
     def _get_vars(cls):
         vars = {}
         vars = {}
@@ -414,7 +418,6 @@ def is_variable(obj):
     return isinstance(obj, ScriptVariable)
     return isinstance(obj, ScriptVariable)
 
 
 
 
-@job('default')
 def run_script(data, request, commit=True, *args, **kwargs):
 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
     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:
     else:
         _run_script()
         _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):
 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.
     defined name in place of the actual module name.
     """
     """
     scripts = OrderedDict()
     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.
     # defined.
     for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
     for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
         # Remove cached module to ensure consistency with filesystem
         # 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(
             for r in JobResult.objects.filter(
                 obj_type=report_content_type,
                 obj_type=report_content_type,
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-            ).defer('data')
+            ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
 
 
         ret = []
         ret = []
@@ -588,7 +588,8 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
             run_report,
             run_report,
             report.full_name,
             report.full_name,
             report_content_type,
             report_content_type,
-            request.user
+            request.user,
+            job_timeout=report.job_timeout
         )
         )
 
 
         return redirect('extras:report_result', job_result_pk=job_result.pk)
         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(
             for r in JobResult.objects.filter(
                 obj_type=script_content_type,
                 obj_type=script_content_type,
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
                 status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
-            ).defer('data')
+            ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
 
 
         for _scripts in scripts.values():
         for _scripts in scripts.values():
@@ -708,6 +709,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
             commit = form.cleaned_data.pop('_commit')
             commit = form.cleaned_data.pop('_commit')
 
 
             script_content_type = ContentType.objects.get(app_label='extras', model='script')
             script_content_type = ContentType.objects.get(app_label='extras', model='script')
+
             job_result = JobResult.enqueue_job(
             job_result = JobResult.enqueue_job(
                 run_script,
                 run_script,
                 script.full_name,
                 script.full_name,
@@ -715,7 +717,8 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
                 request.user,
                 request.user,
                 data=form.cleaned_data,
                 data=form.cleaned_data,
                 request=copy_safe_request(request),
                 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)
             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(),
         queryset=VMInterface.objects.all(),
         label='VM interface (ID)',
         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(
     assigned_to_interface = django_filters.BooleanFilter(
         method='_assigned_to_interface',
         method='_assigned_to_interface',
         label='Is assigned to an interface',
         label='Is assigned to an interface',
@@ -613,7 +618,17 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         )
         )
 
 
     def _assigned_to_interface(self, queryset, name, value):
     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):
 class FHRPGroupFilterSet(NetBoxModelFilterSet):

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

@@ -377,12 +377,16 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
         label=_('Rack')
         label=_('Rack')
     )
     )
     min_vid = forms.IntegerField(
     min_vid = forms.IntegerField(
+        required=False,
         min_value=VLAN_VID_MIN,
         min_value=VLAN_VID_MIN,
         max_value=VLAN_VID_MAX,
         max_value=VLAN_VID_MAX,
+        label='Minimum VID'
     )
     )
     max_vid = forms.IntegerField(
     max_vid = forms.IntegerField(
+        required=False,
         min_value=VLAN_VID_MIN,
         min_value=VLAN_VID_MIN,
         max_value=VLAN_VID_MAX,
         max_value=VLAN_VID_MAX,
+        label='Maximum VID'
     )
     )
     tag = TagFilterField(model)
     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 .fhrp import *
+from .vrfs import *
 from .ip import *
 from .ip import *
 from .services import *
 from .services import *
 from .vlans import *
 from .vlans import *
-from .vrfs import *
 
 
 __all__ = (
 __all__ = (
     'ASN',
     'ASN',

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

@@ -771,6 +771,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         VMInterface.objects.bulk_create(vminterfaces)
         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 = (
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             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.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.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.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='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::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::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::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::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(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
         )
         )
         IPAddress.objects.bulk_create(ipaddresses)
         IPAddress.objects.bulk_create(ipaddresses)
 
 
     def test_family(self):
     def test_family(self):
+        params = {'family': '4'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'family': '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):
     def test_dns_name(self):
         params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
         params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
@@ -814,9 +824,9 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
     def test_parent(self):
     def test_parent(self):
         params = {'parent': '10.0.0.0/24'}
         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'}
         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):
     def test_filter_address(self):
         # Check IPv4 and IPv6, with and without a mask
         # Check IPv4 and IPv6, with and without a mask
@@ -835,7 +845,7 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
     def test_mask_length(self):
     def test_mask_length(self):
         params = {'mask_length': '24'}
         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):
     def test_vrf(self):
         vrfs = VRF.objects.all()[:2]
         vrfs = VRF.objects.all()[:2]
@@ -872,11 +882,16 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'vminterface': ['Interface 1', 'Interface 2']}
         params = {'vminterface': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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):
     def test_assigned_to_interface(self):
         params = {'assigned_to_interface': 'true'}
         params = {'assigned_to_interface': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
         params = {'assigned_to_interface': 'false'}
         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):
     def test_status(self):
         params = {'status': [PrefixStatusChoices.STATUS_DEPRECATED, PrefixStatusChoices.STATUS_RESERVED]}
         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()
 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):
     def get_all_permissions(self, user_obj, obj=None):
         if not user_obj.is_active or user_obj.is_anonymous:
         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)",
         description="Days to retain changelog history (set to zero for unlimited)",
         field=forms.IntegerField
         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(
     ConfigParam(
         name='MAPS_URL',
         name='MAPS_URL',
         label='Maps URL',
         label='Maps URL',

+ 1 - 1
netbox/netbox/settings.py

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

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

@@ -19,8 +19,7 @@ from circuits.models import Circuit, Provider
 from dcim.models import (
 from dcim.models import (
     Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site,
     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 extras.tables import ObjectChangeTable
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
 from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
@@ -48,13 +47,6 @@ class HomeView(View):
             pk__lt=F('_path__destination_id')
             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():
         def build_stats():
             org = (
             org = (
                 ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
                 ("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
@@ -150,7 +142,6 @@ class HomeView(View):
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'search_form': SearchForm(),
             'search_form': SearchForm(),
             'stats': build_stats(),
             'stats': build_stats(),
-            'report_results': report_results,
             'changelog_table': changelog_table,
             'changelog_table': changelog_table,
             'new_release': new_release,
             'new_release': new_release,
         })
         })

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

@@ -1,40 +1,54 @@
 {% load helpers %}
 {% load helpers %}
 
 
 <div class="card">
 <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 %}
             {% endif %}
-        </tr>
-        {% endfor %}
-    </table>
+        {% endif %}
+
     {% else %}
     {% else %}
         <div class="text-muted">
         <div class="text-muted">
             None
             None
         </div>
         </div>
     {% endif %}
     {% endif %}
     </div>
     </div>
+
     {% if perms.dcim.add_device %}
     {% if perms.dcim.add_device %}
         {% if object|meta:'verbose_name' == 'rack' %}
         {% if object|meta:'verbose_name' == 'rack' %}
         <div class="card-footer text-end noprint">
         <div class="card-footer text-end noprint">

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

@@ -52,6 +52,10 @@
         {% if object.installed_module %}
         {% if object.installed_module %}
           {% with module=object.installed_module %}
           {% with module=object.installed_module %}
             <table class="table table-hover attr-table">
             <table class="table table-hover attr-table">
+              <tr>
+                <th scope="row">Module</th>
+                <td>{{ module|linkify }}</td>
+              </tr>
               <tr>
               <tr>
                 <th scope="row">Manufacturer</th>
                 <th scope="row">Manufacturer</th>
                 <td>{{ module.module_type.manufacturer|linkify }}</td>
                 <td>{{ module.module_type.manufacturer|linkify }}</td>
@@ -60,6 +64,14 @@
                 <th scope="row">Module Type</th>
                 <th scope="row">Module Type</th>
                 <td>{{ module.module_type|linkify }}</td>
                 <td>{{ module.module_type|linkify }}</td>
               </tr>
               </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>
             </table>
           {% endwith %}
           {% endwith %}
         {% else %}
         {% else %}

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

@@ -188,6 +188,16 @@
                 {% endif %}
                 {% endif %}
               </td>
               </td>
             </tr>
             </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>
             <tr>
               <th scope="row">VLANs</th>
               <th scope="row">VLANs</th>
               <td class="text-end">
               <td class="text-end">

+ 6 - 4
netbox/templates/login.html

@@ -39,11 +39,13 @@
       </form>
       </form>
     </div>
     </div>
 
 
-    {# TODO: Improve the design & layout #}
     {% if auth_backends %}
     {% 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 %}
       {% endfor %}
     {% endif %}
     {% 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.models import ObjectChange
 from extras.tables import ObjectChangeTable
 from extras.tables import ObjectChangeTable
+from netbox.authentication import get_auth_backend_display
 from netbox.config import get_config
 from netbox.config import get_config
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
 from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm
@@ -43,9 +44,13 @@ class LoginView(View):
             logger = logging.getLogger('netbox.auth.login')
             logger = logging.getLogger('netbox.auth.login')
             return self.redirect_to_next(request, logger)
             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, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
-            'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS),
+            'auth_backends': auth_backends,
         })
         })
 
 
     def post(self, request):
     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
             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
     return values
 
 

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

@@ -136,6 +136,18 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm):
             'vrf',
             '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):
     def clean_enabled(self):
         # Make sure enabled is True when it's not included in the uploaded data
         # Make sure enabled is True when it's not included in the uploaded data
         if 'enabled' not in self.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 netbox.forms import NetBoxModelForm
-from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect
+from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect
 from wireless.models import *
 from wireless.models import *
 
 
 __all__ = (
 __all__ = (
@@ -31,22 +30,63 @@ class WirelessLANForm(NetBoxModelForm):
         queryset=WirelessLANGroup.objects.all(),
         queryset=WirelessLANGroup.objects.all(),
         required=False
         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(
     vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
-        label='VLAN'
+        label='VLAN',
+        query_params={
+            'site_id': '$site',
+            'group_id': '$vlan_group',
+        }
     )
     )
 
 
     fieldsets = (
     fieldsets = (
         ('Wireless LAN', ('ssid', 'group', 'description', 'tags')),
         ('Wireless LAN', ('ssid', 'group', 'description', 'tags')),
-        ('VLAN', ('vlan',)),
+        ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
         ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')),
     )
     )
 
 
     class Meta:
     class Meta:
         model = WirelessLAN
         model = WirelessLAN
         fields = [
         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 = {
         widgets = {
             'auth_type': StaticSelect,
             '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-cors-headers==3.11.0
 django-debug-toolbar==3.2.4
 django-debug-toolbar==3.2.4
 django-filter==21.1
 django-filter==21.1
@@ -18,7 +18,7 @@ gunicorn==20.1.0
 Jinja2==3.0.3
 Jinja2==3.0.3
 Markdown==3.3.6
 Markdown==3.3.6
 markdown-include==0.6.0
 markdown-include==0.6.0
-mkdocs-material==8.2.8
+mkdocs-material==8.2.9
 mkdocstrings==0.17.0
 mkdocstrings==0.17.0
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==9.1.0
 Pillow==9.1.0
@@ -27,7 +27,7 @@ PyYAML==6.0
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
 social-auth-core==4.2.0
 social-auth-core==4.2.0
 svgwrite==1.4.2
 svgwrite==1.4.2
-tablib==3.2.0
+tablib==3.2.1
 tzdata==2022.1
 tzdata==2022.1
 
 
 # Workaround for #7401
 # Workaround for #7401