mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
commit
7cd9bcd3f5
4
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
@ -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:
|
||||
|
@ -85,6 +85,20 @@ As you can see, reports are completely customizable. Validation logic can be as
|
||||
!!! warning
|
||||
Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data.
|
||||
|
||||
## Report Attributes
|
||||
|
||||
### `description`
|
||||
|
||||
A human-friendly description of what your report does.
|
||||
|
||||
### `job_timeout`
|
||||
|
||||
Set the maximum allowed runtime for the report. If not set, `RQ_DEFAULT_TIMEOUT` will be used.
|
||||
|
||||
!!! info "This feature was introduced in v3.2.1"
|
||||
|
||||
## Logging
|
||||
|
||||
The following methods are available to log results within a report:
|
||||
|
||||
* log(message)
|
||||
|
@ -2,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.
|
||||
|
@ -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"
|
||||
|
@ -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'),
|
||||
|
@ -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)',
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -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."
|
||||
})
|
||||
|
@ -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()
|
||||
|
@ -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']
|
||||
|
@ -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')
|
||||
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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',),
|
||||
|
@ -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})
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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]}
|
||||
|
@ -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:
|
||||
|
@ -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',
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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">—</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">—</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">
|
||||
|
@ -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 %}
|
||||
|
@ -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">
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user