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 %}
-
- Non-Racked Devices -
-
-{% if nonracked_devices %} - - - - - - - - {% for device in nonracked_devices %} - - - - - {% if device.parent_bay %} - - - {% else %} - +
+ Non-Racked Devices +
+
+ {% if nonracked_devices %} +
NameRoleTypeParent Device
- {{ device }} - {{ device.device_role }}{{ device.device_type }}{{ device.parent_bay.device|linkify }}{{ device.parent_bay }}
+ + + + + + + {% for device in nonracked_devices %} + + + + + {% if device.parent_bay %} + + + {% else %} + + {% endif %} + + {% endfor %} +
NameRoleTypeParent Device
+ {{ device }} + {{ device.device_role }}{{ device.device_type }}{{ device.parent_bay.device|linkify }}{{ device.parent_bay }}
+ + {% 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' %} - {# TODO: Improve the design & layout #} {% if auth_backends %} -
Or use an SSO provider:
- {% for name, backend in auth_backends.items %} -

{{ name }}

+
Or use a single sign-on (SSO) provider:
+ {% for name, display in auth_backends.items %} +
+ {% if display.1 %}{% endif %} + {{ display.0 }} +
{% endfor %} {% endif %} diff --git a/netbox/users/views.py b/netbox/users/views.py index 04c0c5155..6a923e77e 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -16,6 +16,7 @@ from social_core.backends.utils import load_backends from extras.models import ObjectChange from extras.tables import ObjectChangeTable +from netbox.authentication import get_auth_backend_display from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm @@ -43,9 +44,13 @@ class LoginView(View): logger = logging.getLogger('netbox.auth.login') return self.redirect_to_next(request, logger) + auth_backends = { + name: get_auth_backend_display(name) for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys() + } + return render(request, self.template_name, { 'form': form, - 'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS), + 'auth_backends': auth_backends, }) def post(self, request): diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 6d45d524d..9a4b011e0 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -144,11 +144,11 @@ def get_selected_values(form, field_name): label for value, label in choices if str(value) in filter_data or None in filter_data ] - if hasattr(field, 'null_option'): - # If the field has a `null_option` attribute set and it is selected, - # add it to the field's grouped choices. - if field.null_option is not None and None in filter_data: - values.append(field.null_option) + # If the field has a `null_option` attribute set and it is selected, + # add it to the field's grouped choices. + if getattr(field, 'null_option', None) and None in filter_data: + values.remove(None) + values.insert(0, field.null_option) return values diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index b0315dd95..eab6fc9e7 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -136,6 +136,18 @@ class VMInterfaceCSVForm(NetBoxModelCSVForm): 'vrf', ) + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + # Limit interface choices for parent & bridge interfaces to the assigned VM + if virtual_machine := data.get('virtual_machine'): + params = { + f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": virtual_machine + } + self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) + self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) + def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data if 'enabled' not in self.data: diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 64c9e96ef..6d7dc84a9 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,8 +1,7 @@ -from dcim.models import Device, Interface, Location, Site -from extras.models import Tag -from ipam.models import VLAN +from dcim.models import Device, Interface, Location, Region, Site, SiteGroup +from ipam.models import VLAN, VLANGroup from netbox.forms import NetBoxModelForm -from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect +from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect from wireless.models import * __all__ = ( @@ -31,22 +30,63 @@ class WirelessLANForm(NetBoxModelForm): queryset=WirelessLANGroup.objects.all(), required=False ) + + region = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group', + null_option='None', + query_params={ + 'site': '$site' + }, + initial_params={ + 'vlans': '$vlan' + } + ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - label='VLAN' + label='VLAN', + query_params={ + 'site_id': '$site', + 'group_id': '$vlan_group', + } ) fieldsets = ( ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), - ('VLAN', ('vlan',)), + ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'auth_type', + 'auth_cipher', 'auth_psk', 'tags', ] widgets = { 'auth_type': StaticSelect, diff --git a/requirements.txt b/requirements.txt index ce0e52087..35867410b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.0.3 +Django==4.0.4 django-cors-headers==3.11.0 django-debug-toolbar==3.2.4 django-filter==21.1 @@ -18,7 +18,7 @@ gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 -mkdocs-material==8.2.8 +mkdocs-material==8.2.9 mkdocstrings==0.17.0 netaddr==0.8.0 Pillow==9.1.0 @@ -27,7 +27,7 @@ PyYAML==6.0 social-auth-app-django==5.0.0 social-auth-core==4.2.0 svgwrite==1.4.2 -tablib==3.2.0 +tablib==3.2.1 tzdata==2022.1 # Workaround for #7401