mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
064e3ff605
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.3.9
|
placeholder: v3.3.10
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.3.9
|
placeholder: v3.3.10
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -137,6 +137,14 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## LOGOUT_REDIRECT_URL
|
||||||
|
|
||||||
|
Default: `'home'`
|
||||||
|
|
||||||
|
The view name or URL to which a user is redirected after logging out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## SESSION_COOKIE_NAME
|
## SESSION_COOKIE_NAME
|
||||||
|
|
||||||
Default: `sessionid`
|
Default: `sessionid`
|
||||||
|
@ -1,18 +1,28 @@
|
|||||||
# NetBox v3.3
|
# NetBox v3.3
|
||||||
|
|
||||||
## v3.3.10 (FUTURE)
|
## v3.3.10 (2022-12-13)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|
||||||
|
* [#9361](https://github.com/netbox-community/netbox/issues/9361) - Add replication controls for module bulk import
|
||||||
|
* [#10255](https://github.com/netbox-community/netbox/issues/10255) - Introduce `LOGOUT_REDIRECT_URL` config parameter to control redirection of user after logout
|
||||||
|
* [#10447](https://github.com/netbox-community/netbox/issues/10447) - Enable reassigning an inventory item from one device to another
|
||||||
|
* [#10516](https://github.com/netbox-community/netbox/issues/10516) - Add vertical frame & cabinet rack types
|
||||||
* [#10748](https://github.com/netbox-community/netbox/issues/10748) - Add provider selection field for provider networks to circuit termination edit view
|
* [#10748](https://github.com/netbox-community/netbox/issues/10748) - Add provider selection field for provider networks to circuit termination edit view
|
||||||
|
* [#11089](https://github.com/netbox-community/netbox/issues/11089) - Permit whitespace in MAC addresses
|
||||||
* [#11119](https://github.com/netbox-community/netbox/issues/11119) - Enable filtering L2VPNs by slug
|
* [#11119](https://github.com/netbox-community/netbox/issues/11119) - Enable filtering L2VPNs by slug
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
* [#11041](https://github.com/netbox-community/netbox/issues/11041) - Correct power utilization percentage precision
|
* [#11041](https://github.com/netbox-community/netbox/issues/11041) - Correct power utilization percentage precision
|
||||||
|
* [#11077](https://github.com/netbox-community/netbox/issues/11077) - Honor configured date format when displaying date custom field values in tables
|
||||||
* [#11087](https://github.com/netbox-community/netbox/issues/11087) - Fix background color of bottom banner content
|
* [#11087](https://github.com/netbox-community/netbox/issues/11087) - Fix background color of bottom banner content
|
||||||
* [#11101](https://github.com/netbox-community/netbox/issues/11101) - Correct circuits count under site view
|
* [#11101](https://github.com/netbox-community/netbox/issues/11101) - Correct circuits count under site view
|
||||||
|
* [#11109](https://github.com/netbox-community/netbox/issues/11109) - Fix nullification of custom object & multi-object fields via REST API
|
||||||
* [#11128](https://github.com/netbox-community/netbox/issues/11128) - Disable ordering changelog table by object to avoid exception
|
* [#11128](https://github.com/netbox-community/netbox/issues/11128) - Disable ordering changelog table by object to avoid exception
|
||||||
|
* [#11142](https://github.com/netbox-community/netbox/issues/11142) - Correct available choices for status under IP range filter form
|
||||||
|
* [#11168](https://github.com/netbox-community/netbox/issues/11168) - Honor `RQ_DEFAULT_TIMEOUT` config parameter when using Redis Sentinel
|
||||||
|
* [#11173](https://github.com/netbox-community/netbox/issues/11173) - Enable missing tags columns for contact, L2VPN lists
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -468,7 +478,7 @@ Custom field UI visibility has no impact on API operation.
|
|||||||
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
|
||||||
* Added the optional `device` field
|
* Added the optional `device` field
|
||||||
* Added the `l2vpn_termination` read-only field
|
* Added the `l2vpn_termination` read-only field
|
||||||
wireless.WirelessLAN
|
* wireless.WirelessLAN
|
||||||
* Added `tenant` field
|
* Added `tenant` field
|
||||||
wireless.WirelessLink
|
* wireless.WirelessLink
|
||||||
* Added `tenant` field
|
* Added `tenant` field
|
||||||
|
@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
|
|||||||
TYPE_4POST = '4-post-frame'
|
TYPE_4POST = '4-post-frame'
|
||||||
TYPE_CABINET = '4-post-cabinet'
|
TYPE_CABINET = '4-post-cabinet'
|
||||||
TYPE_WALLFRAME = 'wall-frame'
|
TYPE_WALLFRAME = 'wall-frame'
|
||||||
|
TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
|
||||||
TYPE_WALLCABINET = 'wall-cabinet'
|
TYPE_WALLCABINET = 'wall-cabinet'
|
||||||
|
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(TYPE_2POST, '2-post frame'),
|
(TYPE_2POST, '2-post frame'),
|
||||||
(TYPE_4POST, '4-post frame'),
|
(TYPE_4POST, '4-post frame'),
|
||||||
(TYPE_CABINET, '4-post cabinet'),
|
(TYPE_CABINET, '4-post cabinet'),
|
||||||
(TYPE_WALLFRAME, 'Wall-mounted frame'),
|
(TYPE_WALLFRAME, 'Wall-mounted frame'),
|
||||||
|
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
|
||||||
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
||||||
|
(TYPE_WALLCABINET_VERTICAL, 'Wall-mounted cabinet (vertical)'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -55,6 +55,8 @@ class MACAddressField(models.Field):
|
|||||||
def to_python(self, value):
|
def to_python(self, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return value
|
return value
|
||||||
|
if type(value) is str:
|
||||||
|
value = value.replace(' ', '')
|
||||||
try:
|
try:
|
||||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||||
except AddrFormatError:
|
except AddrFormatError:
|
||||||
|
@ -14,6 +14,7 @@ from tenancy.models import Tenant
|
|||||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
from wireless.choices import WirelessRoleChoices
|
from wireless.choices import WirelessRoleChoices
|
||||||
|
from .common import ModuleCommonForm
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CableImportForm',
|
'CableImportForm',
|
||||||
@ -442,28 +443,40 @@ class DeviceImportForm(BaseDeviceImportForm):
|
|||||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||||
|
|
||||||
|
|
||||||
class ModuleImportForm(NetBoxModelImportForm):
|
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
|
||||||
device = CSVModelChoiceField(
|
device = CSVModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
to_field_name='name'
|
to_field_name='name',
|
||||||
|
help_text=_('The device in which this module is installed')
|
||||||
)
|
)
|
||||||
module_bay = CSVModelChoiceField(
|
module_bay = CSVModelChoiceField(
|
||||||
queryset=ModuleBay.objects.all(),
|
queryset=ModuleBay.objects.all(),
|
||||||
to_field_name='name'
|
to_field_name='name',
|
||||||
|
help_text=_('The module bay in which this module is installed')
|
||||||
)
|
)
|
||||||
module_type = CSVModelChoiceField(
|
module_type = CSVModelChoiceField(
|
||||||
queryset=ModuleType.objects.all(),
|
queryset=ModuleType.objects.all(),
|
||||||
to_field_name='model'
|
to_field_name='model',
|
||||||
|
help_text=_('The type of module')
|
||||||
)
|
)
|
||||||
status = CSVChoiceField(
|
status = CSVChoiceField(
|
||||||
choices=ModuleStatusChoices,
|
choices=ModuleStatusChoices,
|
||||||
help_text=_('Operational status')
|
help_text=_('Operational status')
|
||||||
)
|
)
|
||||||
|
replicate_components = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
help_text=_('Automatically populate components associated with this module type (enabled by default)')
|
||||||
|
)
|
||||||
|
adopt_components = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
help_text=_('Adopt already existing components')
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Module
|
model = Module
|
||||||
fields = (
|
fields = (
|
||||||
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments', 'tags',
|
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments',
|
||||||
|
'replicate_components', 'adopt_components', 'tags',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
@ -474,6 +487,13 @@ class ModuleImportForm(NetBoxModelImportForm):
|
|||||||
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
|
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
|
||||||
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
|
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
|
||||||
|
|
||||||
|
def clean_replicate_components(self):
|
||||||
|
# Make sure replicate_components is True when it's not included in the uploaded data
|
||||||
|
if 'replicate_components' not in self.data:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return self.cleaned_data['replicate_components']
|
||||||
|
|
||||||
|
|
||||||
class ChildDeviceImportForm(BaseDeviceImportForm):
|
class ChildDeviceImportForm(BaseDeviceImportForm):
|
||||||
parent = CSVModelChoiceField(
|
parent = CSVModelChoiceField(
|
||||||
|
@ -6,6 +6,7 @@ from dcim.constants import *
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'InterfaceCommonForm',
|
'InterfaceCommonForm',
|
||||||
|
'ModuleCommonForm'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -48,3 +49,61 @@ class InterfaceCommonForm(forms.Form):
|
|||||||
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
|
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
|
||||||
f"the interface's parent device/VM, or they must be global"
|
f"the interface's parent device/VM, or they must be global"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleCommonForm(forms.Form):
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
replicate_components = self.cleaned_data.get("replicate_components")
|
||||||
|
adopt_components = self.cleaned_data.get("adopt_components")
|
||||||
|
device = self.cleaned_data.get('device')
|
||||||
|
module_type = self.cleaned_data.get('module_type')
|
||||||
|
module_bay = self.cleaned_data.get('module_bay')
|
||||||
|
|
||||||
|
if adopt_components:
|
||||||
|
self.instance._adopt_components = True
|
||||||
|
|
||||||
|
# Bail out if we are not installing a new module or if we are not replicating components
|
||||||
|
if self.instance.pk or not replicate_components:
|
||||||
|
self.instance._disable_replication = True
|
||||||
|
return
|
||||||
|
|
||||||
|
for templates, component_attribute in [
|
||||||
|
("consoleporttemplates", "consoleports"),
|
||||||
|
("consoleserverporttemplates", "consoleserverports"),
|
||||||
|
("interfacetemplates", "interfaces"),
|
||||||
|
("powerporttemplates", "powerports"),
|
||||||
|
("poweroutlettemplates", "poweroutlets"),
|
||||||
|
("rearporttemplates", "rearports"),
|
||||||
|
("frontporttemplates", "frontports")
|
||||||
|
]:
|
||||||
|
# Prefetch installed components
|
||||||
|
installed_components = {
|
||||||
|
component.name: component for component in getattr(device, component_attribute).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the templates for the module type.
|
||||||
|
for template in getattr(module_type, templates).all():
|
||||||
|
# Installing modules with placeholders require that the bay has a position value
|
||||||
|
if MODULE_TOKEN in template.name and not module_bay.position:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
"Cannot install module with placeholder values in a module bay with no position defined"
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
||||||
|
existing_item = installed_components.get(resolved_name)
|
||||||
|
|
||||||
|
# It is not possible to adopt components already belonging to a module
|
||||||
|
if adopt_components and existing_item and existing_item.module:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
||||||
|
f"to a module"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we are not adopting components we error if the component exists
|
||||||
|
if not adopt_components and resolved_name in installed_components:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f"{template.component_model.__name__} - {resolved_name} already exists"
|
||||||
|
)
|
||||||
|
@ -17,7 +17,7 @@ from utilities.forms import (
|
|||||||
)
|
)
|
||||||
from virtualization.models import Cluster, ClusterGroup
|
from virtualization.models import Cluster, ClusterGroup
|
||||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||||
from .common import InterfaceCommonForm
|
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CableForm',
|
'CableForm',
|
||||||
@ -662,7 +662,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||||
|
|
||||||
|
|
||||||
class ModuleForm(NetBoxModelForm):
|
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||||
device = DynamicModelChoiceField(
|
device = DynamicModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
initial_params={
|
initial_params={
|
||||||
@ -727,68 +727,6 @@ class ModuleForm(NetBoxModelForm):
|
|||||||
self.fields['adopt_components'].initial = False
|
self.fields['adopt_components'].initial = False
|
||||||
self.fields['adopt_components'].disabled = True
|
self.fields['adopt_components'].disabled = True
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
|
|
||||||
# If replicate_components is False, disable automatic component replication on the instance
|
|
||||||
if self.instance.pk or not self.cleaned_data['replicate_components']:
|
|
||||||
self.instance._disable_replication = True
|
|
||||||
|
|
||||||
if self.cleaned_data['adopt_components']:
|
|
||||||
self.instance._adopt_components = True
|
|
||||||
|
|
||||||
return super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
replicate_components = self.cleaned_data.get("replicate_components")
|
|
||||||
adopt_components = self.cleaned_data.get("adopt_components")
|
|
||||||
device = self.cleaned_data['device']
|
|
||||||
module_type = self.cleaned_data['module_type']
|
|
||||||
module_bay = self.cleaned_data['module_bay']
|
|
||||||
|
|
||||||
# Bail out if we are not installing a new module or if we are not replicating components
|
|
||||||
if self.instance.pk or not replicate_components:
|
|
||||||
return
|
|
||||||
|
|
||||||
for templates, component_attribute in [
|
|
||||||
("consoleporttemplates", "consoleports"),
|
|
||||||
("consoleserverporttemplates", "consoleserverports"),
|
|
||||||
("interfacetemplates", "interfaces"),
|
|
||||||
("powerporttemplates", "powerports"),
|
|
||||||
("poweroutlettemplates", "poweroutlets"),
|
|
||||||
("rearporttemplates", "rearports"),
|
|
||||||
("frontporttemplates", "frontports")
|
|
||||||
]:
|
|
||||||
# Prefetch installed components
|
|
||||||
installed_components = {
|
|
||||||
component.name: component for component in getattr(device, component_attribute).all()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get the templates for the module type.
|
|
||||||
for template in getattr(module_type, templates).all():
|
|
||||||
# Installing modules with placeholders require that the bay has a position value
|
|
||||||
if MODULE_TOKEN in template.name and not module_bay.position:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
"Cannot install module with placeholder values in a module bay with no position defined"
|
|
||||||
)
|
|
||||||
|
|
||||||
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
|
|
||||||
existing_item = installed_components.get(resolved_name)
|
|
||||||
|
|
||||||
# It is not possible to adopt components already belonging to a module
|
|
||||||
if adopt_components and existing_item and existing_item.module:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
|
|
||||||
f"to a module"
|
|
||||||
)
|
|
||||||
|
|
||||||
# If we are not adopting components we error if the component exists
|
|
||||||
if not adopt_components and resolved_name in installed_components:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
f"{template.component_model.__name__} - {resolved_name} already exists"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CableForm(TenancyForm, NetBoxModelForm):
|
class CableForm(TenancyForm, NetBoxModelForm):
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
@ -1627,6 +1565,13 @@ class InventoryItemForm(DeviceComponentForm):
|
|||||||
('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
|
('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Specifically allow editing the device of IntentoryItems
|
||||||
|
if self.instance.pk:
|
||||||
|
self.fields['device'].disabled = False
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryItem
|
model = InventoryItem
|
||||||
fields = [
|
fields = [
|
||||||
|
@ -1129,3 +1129,20 @@ class InventoryItem(MPTTModel, ComponentModel):
|
|||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"parent": "Cannot assign self as parent."
|
"parent": "Cannot assign self as parent."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Validation for moving InventoryItems
|
||||||
|
if self.pk:
|
||||||
|
# Cannot move an InventoryItem to another device if it has a parent
|
||||||
|
if self.parent and self.parent.device != self.device:
|
||||||
|
raise ValidationError({
|
||||||
|
"parent": "Parent inventory item does not belong to the same device."
|
||||||
|
})
|
||||||
|
|
||||||
|
# Prevent moving InventoryItems with children
|
||||||
|
first_child = self.get_children().first()
|
||||||
|
if first_child and first_child.device != self.device:
|
||||||
|
raise ValidationError("Cannot move an inventory item with dependent children")
|
||||||
|
|
||||||
|
# When moving an InventoryItem to another device, remove any associated component
|
||||||
|
if self.component and self.component.device != self.device:
|
||||||
|
self.component = None
|
||||||
|
@ -49,7 +49,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
|||||||
model = models.Manufacturer
|
model = models.Manufacturer
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||||
'contacts', 'actions', 'created', 'last_updated',
|
'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||||
|
@ -17,6 +17,7 @@ from dcim.constants import *
|
|||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from ipam.models import ASN, RIR, VLAN, VRF
|
from ipam.models import ASN, RIR, VLAN, VRF
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
|
from utilities.forms.choices import ImportFormatChoices
|
||||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||||
from wireless.models import WirelessLAN
|
from wireless.models import WirelessLAN
|
||||||
|
|
||||||
@ -1950,6 +1951,54 @@ class ModuleTestCase(
|
|||||||
self.assertHttpStatus(self.client.post(**request), 302)
|
self.assertHttpStatus(self.client.post(**request), 302)
|
||||||
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_module_bulk_replication(self):
|
||||||
|
self.add_permissions('dcim.add_module')
|
||||||
|
|
||||||
|
# Add 5 InterfaceTemplates to a ModuleType
|
||||||
|
module_type = ModuleType.objects.first()
|
||||||
|
interface_templates = [
|
||||||
|
InterfaceTemplate(module_type=module_type, name=f'Interface {i}')
|
||||||
|
for i in range(1, 6)
|
||||||
|
]
|
||||||
|
InterfaceTemplate.objects.bulk_create(interface_templates)
|
||||||
|
|
||||||
|
# Create a module *without* replicating components
|
||||||
|
device = Device.objects.get(name='Device 2')
|
||||||
|
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
|
||||||
|
csv_data = [
|
||||||
|
"device,module_bay,module_type,status,replicate_components",
|
||||||
|
f"{device.name},{module_bay.name},{module_type.model},active,false"
|
||||||
|
]
|
||||||
|
request = {
|
||||||
|
'path': self._get_url('import'),
|
||||||
|
'data': {
|
||||||
|
'data': '\n'.join(csv_data),
|
||||||
|
'format': ImportFormatChoices.CSV,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initial_count = Module.objects.count()
|
||||||
|
self.assertHttpStatus(self.client.post(**request), 200)
|
||||||
|
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
|
||||||
|
self.assertEqual(Interface.objects.filter(device=device).count(), 0)
|
||||||
|
|
||||||
|
# Create a second module (in the next bay) with replicated components
|
||||||
|
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 5')
|
||||||
|
csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},active,true"
|
||||||
|
request = {
|
||||||
|
'path': self._get_url('import'),
|
||||||
|
'data': {
|
||||||
|
'data': '\n'.join(csv_data),
|
||||||
|
'format': ImportFormatChoices.CSV,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initial_count = Module.objects.count()
|
||||||
|
self.assertHttpStatus(self.client.post(**request), 200)
|
||||||
|
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
|
||||||
|
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
|
||||||
|
|
||||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
def test_module_component_adoption(self):
|
def test_module_component_adoption(self):
|
||||||
self.add_permissions('dcim.add_module')
|
self.add_permissions('dcim.add_module')
|
||||||
@ -1987,6 +2036,50 @@ class ModuleTestCase(
|
|||||||
# Check that the Interface now has a module
|
# Check that the Interface now has a module
|
||||||
self.assertIsNotNone(interface.module)
|
self.assertIsNotNone(interface.module)
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
|
def test_module_bulk_adoption(self):
|
||||||
|
self.add_permissions('dcim.add_module')
|
||||||
|
|
||||||
|
interface_name = "Interface-1"
|
||||||
|
|
||||||
|
# Add an interface to the ModuleType
|
||||||
|
module_type = ModuleType.objects.first()
|
||||||
|
InterfaceTemplate(module_type=module_type, name=interface_name).save()
|
||||||
|
|
||||||
|
form_data = self.form_data.copy()
|
||||||
|
device = Device.objects.get(pk=form_data['device'])
|
||||||
|
|
||||||
|
# Create an interface to be adopted
|
||||||
|
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
|
||||||
|
interface.save()
|
||||||
|
|
||||||
|
# Ensure that interface is created with no module
|
||||||
|
self.assertIsNone(interface.module)
|
||||||
|
|
||||||
|
# Create a module with adopted components
|
||||||
|
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
|
||||||
|
csv_data = [
|
||||||
|
"device,module_bay,module_type,status,replicate_components,adopt_components",
|
||||||
|
f"{device.name},{module_bay.name},{module_type.model},active,false,true"
|
||||||
|
]
|
||||||
|
request = {
|
||||||
|
'path': self._get_url('import'),
|
||||||
|
'data': {
|
||||||
|
'data': '\n'.join(csv_data),
|
||||||
|
'format': ImportFormatChoices.CSV,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initial_count = self._get_queryset().count()
|
||||||
|
self.assertHttpStatus(self.client.post(**request), 200)
|
||||||
|
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
|
||||||
|
|
||||||
|
# Re-retrieve interface to get new module id
|
||||||
|
interface.refresh_from_db()
|
||||||
|
|
||||||
|
# Check that the Interface now has a module
|
||||||
|
self.assertIsNotNone(interface.module)
|
||||||
|
|
||||||
|
|
||||||
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||||
model = ConsolePort
|
model = ConsolePort
|
||||||
|
@ -72,7 +72,7 @@ class CustomFieldsDataField(Field):
|
|||||||
|
|
||||||
# Serialize object and multi-object values
|
# Serialize object and multi-object values
|
||||||
for cf in self._get_custom_fields():
|
for cf in self._get_custom_fields():
|
||||||
if cf.name in data and cf.type in (
|
if cf.name in data and data[cf.name] not in (None, []) and cf.type in (
|
||||||
CustomFieldTypeChoices.TYPE_OBJECT,
|
CustomFieldTypeChoices.TYPE_OBJECT,
|
||||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
CustomFieldTypeChoices.TYPE_MULTIOBJECT
|
||||||
):
|
):
|
||||||
|
@ -905,6 +905,18 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
[vlans[1].pk, vlans[2].pk]
|
[vlans[1].pk, vlans[2].pk]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Clear related objects
|
||||||
|
data = {
|
||||||
|
'custom_fields': {
|
||||||
|
'object_field': None,
|
||||||
|
'multiobject_field': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response = self.client.patch(url, data, format='json', **self.header)
|
||||||
|
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
|
self.assertIsNone(response.data['custom_fields']['object_field'])
|
||||||
|
self.assertListEqual(response.data['custom_fields']['multiobject_field'], [])
|
||||||
|
|
||||||
def test_minimum_maximum_values_validation(self):
|
def test_minimum_maximum_values_validation(self):
|
||||||
site2 = Site.objects.get(name='Site 2')
|
site2 = Site.objects.get(name='Site 2')
|
||||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||||
|
@ -249,7 +249,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
null_option='Global'
|
null_option='Global'
|
||||||
)
|
)
|
||||||
status = MultipleChoiceField(
|
status = MultipleChoiceField(
|
||||||
choices=PrefixStatusChoices,
|
choices=IPRangeStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
role_id = DynamicModelMultipleChoiceField(
|
role_id = DynamicModelMultipleChoiceField(
|
||||||
|
@ -31,16 +31,16 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
|
|||||||
)
|
)
|
||||||
comments = columns.MarkdownColumn()
|
comments = columns.MarkdownColumn()
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:prefix_list'
|
url_name='ipam:l2vpn_list'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = L2VPN
|
model = L2VPN
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
|
'pk', 'name', 'slug', 'identifier', 'type', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
|
||||||
'description', 'comments', 'tags', 'created', 'last_updated', 'actions',
|
'description', 'comments', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions')
|
default_columns = ('pk', 'name', 'identifier', 'type', 'description')
|
||||||
|
|
||||||
|
|
||||||
class L2VPNTerminationTable(NetBoxTable):
|
class L2VPNTerminationTable(NetBoxTable):
|
||||||
|
@ -152,6 +152,9 @@ LOGIN_REQUIRED = False
|
|||||||
# re-authenticate. (Default: 1209600 [14 days])
|
# re-authenticate. (Default: 1209600 [14 days])
|
||||||
LOGIN_TIMEOUT = None
|
LOGIN_TIMEOUT = None
|
||||||
|
|
||||||
|
# The view name or URL to which users are redirected after logging out.
|
||||||
|
LOGOUT_REDIRECT_URL = 'home'
|
||||||
|
|
||||||
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
|
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
|
||||||
# the default value of this setting is derived from the installed location.
|
# the default value of this setting is derived from the installed location.
|
||||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||||
|
@ -98,6 +98,7 @@ LOGGING = getattr(configuration, 'LOGGING', {})
|
|||||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||||
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
||||||
|
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
|
||||||
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
|
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
|
||||||
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
|
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
|
||||||
PLUGINS = getattr(configuration, 'PLUGINS', [])
|
PLUGINS = getattr(configuration, 'PLUGINS', [])
|
||||||
@ -622,8 +623,6 @@ if TASKS_REDIS_USING_SENTINEL:
|
|||||||
RQ_PARAMS = {
|
RQ_PARAMS = {
|
||||||
'SENTINELS': TASKS_REDIS_SENTINELS,
|
'SENTINELS': TASKS_REDIS_SENTINELS,
|
||||||
'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE,
|
'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE,
|
||||||
'DB': TASKS_REDIS_DATABASE,
|
|
||||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
|
||||||
'SOCKET_TIMEOUT': None,
|
'SOCKET_TIMEOUT': None,
|
||||||
'CONNECTION_KWARGS': {
|
'CONNECTION_KWARGS': {
|
||||||
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
||||||
@ -633,12 +632,14 @@ else:
|
|||||||
RQ_PARAMS = {
|
RQ_PARAMS = {
|
||||||
'HOST': TASKS_REDIS_HOST,
|
'HOST': TASKS_REDIS_HOST,
|
||||||
'PORT': TASKS_REDIS_PORT,
|
'PORT': TASKS_REDIS_PORT,
|
||||||
'DB': TASKS_REDIS_DATABASE,
|
|
||||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
|
||||||
'SSL': TASKS_REDIS_SSL,
|
'SSL': TASKS_REDIS_SSL,
|
||||||
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
|
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
|
||||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
|
||||||
}
|
}
|
||||||
|
RQ_PARAMS.update({
|
||||||
|
'DB': TASKS_REDIS_DATABASE,
|
||||||
|
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||||
|
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||||
|
})
|
||||||
|
|
||||||
RQ_QUEUES = {
|
RQ_QUEUES = {
|
||||||
RQ_QUEUE_HIGH: RQ_PARAMS,
|
RQ_QUEUE_HIGH: RQ_PARAMS,
|
||||||
|
@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
|
|||||||
from django.db.models import DateField, DateTimeField
|
from django.db.models import DateField, DateTimeField
|
||||||
from django.template import Context, Template
|
from django.template import Context, Template
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.dateparse import parse_date
|
||||||
from django.utils.encoding import escape_uri_path
|
from django.utils.encoding import escape_uri_path
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
@ -51,6 +52,10 @@ class DateColumn(tables.DateColumn):
|
|||||||
tables and null when exporting data. It is registered in the tables library to use this class instead of the
|
tables and null when exporting data. It is registered in the tables library to use this class instead of the
|
||||||
default, making this behavior consistent in all fields of type DateField.
|
default, making this behavior consistent in all fields of type DateField.
|
||||||
"""
|
"""
|
||||||
|
def render(self, value):
|
||||||
|
if value:
|
||||||
|
return date_format(value, format="SHORT_DATE_FORMAT")
|
||||||
|
|
||||||
def value(self, value):
|
def value(self, value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@ -474,6 +479,8 @@ class CustomFieldColumn(tables.Column):
|
|||||||
))
|
))
|
||||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
|
||||||
return render_markdown(value)
|
return render_markdown(value)
|
||||||
|
if self.customfield.type == CustomFieldTypeChoices.TYPE_DATE and value:
|
||||||
|
return date_format(parse_date(value), format="SHORT_DATE_FORMAT")
|
||||||
if value is not None:
|
if value is not None:
|
||||||
obj = self.customfield.deserialize(value)
|
obj = self.customfield.deserialize(value)
|
||||||
return mark_safe(self._linkify_item(obj))
|
return mark_safe(self._linkify_item(obj))
|
||||||
|
@ -37,10 +37,13 @@ class ContactRoleTable(NetBoxTable):
|
|||||||
name = tables.Column(
|
name = tables.Column(
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
tags = columns.TagColumn(
|
||||||
|
url_name='tenancy:contactrole_list'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ContactRole
|
model = ContactRole
|
||||||
fields = ('pk', 'name', 'description', 'slug', 'created', 'last_updated', 'actions')
|
fields = ('pk', 'name', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions')
|
||||||
default_columns = ('pk', 'name', 'description')
|
default_columns = ('pk', 'name', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.contrib.auth.models import update_last_login
|
from django.contrib.auth.models import update_last_login
|
||||||
from django.contrib.auth.signals import user_logged_in
|
from django.contrib.auth.signals import user_logged_in
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
||||||
@ -143,7 +143,7 @@ class LogoutView(View):
|
|||||||
messages.info(request, "You have logged out.")
|
messages.info(request, "You have logged out.")
|
||||||
|
|
||||||
# Delete session key cookie (if set) upon logout
|
# Delete session key cookie (if set) upon logout
|
||||||
response = HttpResponseRedirect(reverse('home'))
|
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
||||||
response.delete_cookie('session_key')
|
response.delete_cookie('session_key')
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
bleach==5.0.1
|
bleach==5.0.1
|
||||||
Django==4.1.2
|
Django==4.1.2
|
||||||
django-cors-headers==3.13.0
|
django-cors-headers==3.13.0
|
||||||
django-debug-toolbar==3.7.0
|
django-debug-toolbar==3.8.1
|
||||||
django-filter==22.1
|
django-filter==22.1
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
django-mptt==0.14
|
django-mptt==0.14
|
||||||
@ -29,7 +29,7 @@ sentry-sdk==1.11.1
|
|||||||
social-auth-app-django==5.0.0
|
social-auth-app-django==5.0.0
|
||||||
social-auth-core[openidconnect]==4.3.0
|
social-auth-core[openidconnect]==4.3.0
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.2.1
|
tablib==3.3.0
|
||||||
tzdata==2022.7
|
tzdata==2022.7
|
||||||
|
|
||||||
# Workaround for #7401
|
# Workaround for #7401
|
||||||
|
Loading…
Reference in New Issue
Block a user