diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index ae3fe68f3..b5de9bfee 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.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
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 1ce4c0a11..138e0f9b4 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.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
diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md
index bbb03dc27..1989e41c0 100644
--- a/docs/administration/housekeeping.md
+++ b/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.
diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md
index 5649eb9be..2fa046fcf 100644
--- a/docs/configuration/dynamic-settings.md
+++ b/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:
diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md
index 73d29415b..2c3a7002f 100644
--- a/docs/configuration/remote-authentication.md
+++ b/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)
---
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index 02af19726..230b003c6 100644
--- a/docs/customization/custom-scripts.md
+++ b/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:
diff --git a/docs/customization/reports.md b/docs/customization/reports.md
index 3bf6bd8d9..ae4ceb9aa 100644
--- a/docs/customization/reports.md
+++ b/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)
diff --git a/docs/models/dcim/virtualchassis.md b/docs/models/dcim/virtualchassis.md
index 3b6fb9d17..2466b065d 100644
--- a/docs/models/dcim/virtualchassis.md
+++ b/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.
diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md
index 9f354dbd6..cd19a6d65 100644
--- a/docs/release-notes/version-3.2.md
+++ b/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"
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index c5f70c1b6..b0aa1c60c 100644
--- a/netbox/dcim/choices.py
+++ b/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'),
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index a380fbcce..0f4e7cf7e 100644
--- a/netbox/dcim/filtersets.py
+++ b/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)',
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index b166530c8..9e4f5e400 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/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')
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index d9c738cc2..b28c16fad 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/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)
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index 57e2fa820..fe9daf938 100644
--- a/netbox/dcim/forms/models.py
+++ b/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(),
- }
#
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 8618a3b9d..e3e9c1179 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/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(),
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index b363d6ea4..647abe148 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/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,
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index ab5d24867..3ed786000 100644
--- a/netbox/dcim/models/device_components.py
+++ b/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."
+ })
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index d95063601..6ed7b349f 100644
--- a/netbox/dcim/models/devices.py
+++ b/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()
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index 53e3bcceb..d02bd0932 100644
--- a/netbox/dcim/models/sites.py
+++ b/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']
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index f3e1d39e0..25ad1415d 100644
--- a/netbox/dcim/tables/devices.py
+++ b/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')
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 4157fd2e9..2622a1405 100644
--- a/netbox/dcim/views.py
+++ b/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'
diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py
index 64c224cb1..28902c323 100644
--- a/netbox/extras/admin.py
+++ b/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',),
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 4f42b4c93..688f3c7ab 100644
--- a/netbox/extras/api/views.py
+++ b/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})
diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py
index 0607a16c2..51d50d7e1 100644
--- a/netbox/extras/management/commands/housekeeping.py
+++ b/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")
diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py
index de7c5c91b..ee166ae6a 100644
--- a/netbox/extras/management/commands/runreport.py
+++ b/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
diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py
index 0d1dc5cea..12188619f 100644
--- a/netbox/extras/management/commands/runscript.py
+++ b/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,
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index ef0ab8b1f..e614a1258 100644
--- a/netbox/extras/models/models.py
+++ b/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
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 2eb6584c9..0a8a8d89b 100644
--- a/netbox/extras/reports.py
+++ b/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):
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index f80dfaefa..4eacddbeb 100644
--- a/netbox/extras/scripts.py
+++ b/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
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 0a190dd49..9825d10de 100644
--- a/netbox/extras/views.py
+++ b/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)
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index 88b586bf2..53c589bb3 100644
--- a/netbox/ipam/filtersets.py
+++ b/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):
diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py
index 57f39f8c2..bbd6bb97b 100644
--- a/netbox/ipam/forms/filtersets.py
+++ b/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)
diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py
index 1857b7d66..ce09c482a 100644
--- a/netbox/ipam/models/__init__.py
+++ b/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',
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index eaf84ee16..4bb72dce2 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/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]}
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index acb04ce34..6367d6d70 100644
--- a/netbox/netbox/authentication.py
+++ b/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'),
+}
-class ObjectPermissionMixin():
+
+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:
def get_all_permissions(self, user_obj, obj=None):
if not user_obj.is_active or user_obj.is_anonymous:
diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py
index 89de94674..68c96b38a 100644
--- a/netbox/netbox/config/parameters.py
+++ b/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',
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 1e972df8c..4e3017d8d 100644
--- a/netbox/netbox/settings.py
+++ b/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()
diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py
index 5d388be35..fad347c36 100644
--- a/netbox/netbox/views/__init__.py
+++ b/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,
})
diff --git a/netbox/templates/dcim/inc/nonracked_devices.html b/netbox/templates/dcim/inc/nonracked_devices.html
index 7f4da2f24..d4cd58839 100644
--- a/netbox/templates/dcim/inc/nonracked_devices.html
+++ b/netbox/templates/dcim/inc/nonracked_devices.html
@@ -1,40 +1,54 @@
{% load helpers %}
-
-
-{% if nonracked_devices %}
-
-
- Name |
- Role |
- Type |
- Parent Device |
-
- {% for device in nonracked_devices %}
-
-
- {{ device }}
- |
- {{ device.device_role }} |
- {{ device.device_type }} |
- {% if device.parent_bay %}
- {{ device.parent_bay.device|linkify }} |
- {{ device.parent_bay }} |
- {% else %}
- — |
+
+
+ {% if nonracked_devices %}
+
+
+ Name |
+ Role |
+ Type |
+ Parent Device |
+
+ {% for device in nonracked_devices %}
+
+
+ {{ device }}
+ |
+ {{ device.device_role }} |
+ {{ device.device_type }} |
+ {% if device.parent_bay %}
+ {{ device.parent_bay.device|linkify }} |
+ {{ device.parent_bay }} |
+ {% else %}
+ — |
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% if total_nonracked_devices_count > nonracked_devices.count %}
+ {% if object|meta:'verbose_name' == 'site' %}
+
+ Displaying {{ nonracked_devices.count }} of {{ total_nonracked_devices_count }} devices (
View full list)
+
+ {% elif object|meta:'verbose_name' == 'location' %}
+
+ Displaying {{ nonracked_devices.count }} of {{ total_nonracked_devices_count }} devices (
View full list)
+
{% endif %}
-
- {% endfor %}
-
+ {% endif %}
+
{% else %}
None
{% endif %}
+
{% if perms.dcim.add_device %}
{% if object|meta:'verbose_name' == 'rack' %}