Merge pull request #9131 from netbox-community/develop

Release v3.2.1
This commit is contained in:
Jeremy Stretch 2022-04-14 14:12:24 -04:00 committed by GitHub
commit 7cd9bcd3f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 450 additions and 146 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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)
---

View File

@ -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:

View File

@ -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)

View File

@ -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.

View File

@ -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"

View File

@ -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'),

View File

@ -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)',

View File

@ -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')

View File

@ -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)

View File

@ -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(),
}
#

View File

@ -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(),

View File

@ -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,

View File

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

View File

@ -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()

View File

@ -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']

View File

@ -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')

View File

@ -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'

View File

@ -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',),

View File

@ -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})

View File

@ -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")

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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',

View File

@ -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]}

View File

@ -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:

View File

@ -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',

View File

@ -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()

View File

@ -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,
})

View File

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

View File

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

View File

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

View File

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

View File

@ -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):

View File

@ -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

View File

@ -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:

View File

@ -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,

View File

@ -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