mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -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:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.3.9
|
||||
placeholder: v3.3.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
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.3.9
|
||||
placeholder: v3.3.10
|
||||
validations:
|
||||
required: true
|
||||
- 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
|
||||
|
||||
Default: `sessionid`
|
||||
|
@ -1,18 +1,28 @@
|
||||
# NetBox v3.3
|
||||
|
||||
## v3.3.10 (FUTURE)
|
||||
## v3.3.10 (2022-12-13)
|
||||
|
||||
### 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
|
||||
* [#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
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#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
|
||||
* [#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
|
||||
* [#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.
|
||||
* Added the optional `device` field
|
||||
* Added the `l2vpn_termination` read-only field
|
||||
wireless.WirelessLAN
|
||||
* wireless.WirelessLAN
|
||||
* Added `tenant` field
|
||||
wireless.WirelessLink
|
||||
* wireless.WirelessLink
|
||||
* Added `tenant` field
|
||||
|
@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
|
||||
TYPE_4POST = '4-post-frame'
|
||||
TYPE_CABINET = '4-post-cabinet'
|
||||
TYPE_WALLFRAME = 'wall-frame'
|
||||
TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
|
||||
TYPE_WALLCABINET = 'wall-cabinet'
|
||||
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
|
||||
|
||||
CHOICES = (
|
||||
(TYPE_2POST, '2-post frame'),
|
||||
(TYPE_4POST, '4-post frame'),
|
||||
(TYPE_CABINET, '4-post cabinet'),
|
||||
(TYPE_WALLFRAME, 'Wall-mounted frame'),
|
||||
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
|
||||
(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):
|
||||
if value is None:
|
||||
return value
|
||||
if type(value) is str:
|
||||
value = value.replace(' ', '')
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError:
|
||||
|
@ -14,6 +14,7 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
|
||||
from virtualization.models import Cluster
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from .common import ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableImportForm',
|
||||
@ -442,28 +443,40 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
|
||||
class ModuleImportForm(NetBoxModelImportForm):
|
||||
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
|
||||
device = CSVModelChoiceField(
|
||||
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(
|
||||
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(
|
||||
queryset=ModuleType.objects.all(),
|
||||
to_field_name='model'
|
||||
to_field_name='model',
|
||||
help_text=_('The type of module')
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=ModuleStatusChoices,
|
||||
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:
|
||||
model = Module
|
||||
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):
|
||||
@ -474,6 +487,13 @@ class ModuleImportForm(NetBoxModelImportForm):
|
||||
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
|
||||
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):
|
||||
parent = CSVModelChoiceField(
|
||||
|
@ -6,6 +6,7 @@ from dcim.constants import *
|
||||
|
||||
__all__ = (
|
||||
'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 "
|
||||
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 wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from .common import InterfaceCommonForm
|
||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||
|
||||
__all__ = (
|
||||
'CableForm',
|
||||
@ -662,7 +662,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||
|
||||
|
||||
class ModuleForm(NetBoxModelForm):
|
||||
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
initial_params={
|
||||
@ -727,68 +727,6 @@ class ModuleForm(NetBoxModelForm):
|
||||
self.fields['adopt_components'].initial = False
|
||||
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):
|
||||
comments = CommentField()
|
||||
@ -1627,6 +1565,13 @@ class InventoryItemForm(DeviceComponentForm):
|
||||
('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:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
|
@ -1129,3 +1129,20 @@ class InventoryItem(MPTTModel, ComponentModel):
|
||||
raise ValidationError({
|
||||
"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
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'contacts', 'actions', 'created', 'last_updated',
|
||||
'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
|
@ -17,6 +17,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms.choices import ImportFormatChoices
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
@ -1950,6 +1951,54 @@ class ModuleTestCase(
|
||||
self.assertHttpStatus(self.client.post(**request), 302)
|
||||
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=['*'])
|
||||
def test_module_component_adoption(self):
|
||||
self.add_permissions('dcim.add_module')
|
||||
@ -1987,6 +2036,50 @@ class ModuleTestCase(
|
||||
# Check that the Interface now has a 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):
|
||||
model = ConsolePort
|
||||
|
@ -72,7 +72,7 @@ class CustomFieldsDataField(Field):
|
||||
|
||||
# Serialize object and multi-object values
|
||||
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_MULTIOBJECT
|
||||
):
|
||||
|
@ -905,6 +905,18 @@ class CustomFieldAPITest(APITestCase):
|
||||
[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):
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
|
@ -249,7 +249,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
null_option='Global'
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=PrefixStatusChoices,
|
||||
choices=IPRangeStatusChoices,
|
||||
required=False
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
|
@ -31,16 +31,16 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:prefix_list'
|
||||
url_name='ipam:l2vpn_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = L2VPN
|
||||
fields = (
|
||||
'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):
|
||||
|
@ -152,6 +152,9 @@ LOGIN_REQUIRED = False
|
||||
# re-authenticate. (Default: 1209600 [14 days])
|
||||
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 default value of this setting is derived from the installed location.
|
||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||
|
@ -98,6 +98,7 @@ LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
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('/')
|
||||
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
|
||||
PLUGINS = getattr(configuration, 'PLUGINS', [])
|
||||
@ -622,8 +623,6 @@ if TASKS_REDIS_USING_SENTINEL:
|
||||
RQ_PARAMS = {
|
||||
'SENTINELS': TASKS_REDIS_SENTINELS,
|
||||
'MASTER_NAME': TASKS_REDIS_SENTINEL_SERVICE,
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SOCKET_TIMEOUT': None,
|
||||
'CONNECTION_KWARGS': {
|
||||
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
||||
@ -633,12 +632,14 @@ else:
|
||||
RQ_PARAMS = {
|
||||
'HOST': TASKS_REDIS_HOST,
|
||||
'PORT': TASKS_REDIS_PORT,
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SSL': TASKS_REDIS_SSL,
|
||||
'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_QUEUE_HIGH: RQ_PARAMS,
|
||||
|
@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.html import escape
|
||||
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
|
||||
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):
|
||||
return value
|
||||
|
||||
@ -474,6 +479,8 @@ class CustomFieldColumn(tables.Column):
|
||||
))
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and 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:
|
||||
obj = self.customfield.deserialize(value)
|
||||
return mark_safe(self._linkify_item(obj))
|
||||
|
@ -37,10 +37,13 @@ class ContactRoleTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='tenancy:contactrole_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
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')
|
||||
|
||||
|
||||
|
@ -7,7 +7,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import update_last_login
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
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.utils.decorators import method_decorator
|
||||
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.")
|
||||
|
||||
# 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')
|
||||
|
||||
return response
|
||||
|
@ -1,7 +1,7 @@
|
||||
bleach==5.0.1
|
||||
Django==4.1.2
|
||||
django-cors-headers==3.13.0
|
||||
django-debug-toolbar==3.7.0
|
||||
django-debug-toolbar==3.8.1
|
||||
django-filter==22.1
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-mptt==0.14
|
||||
@ -29,7 +29,7 @@ sentry-sdk==1.11.1
|
||||
social-auth-app-django==5.0.0
|
||||
social-auth-core[openidconnect]==4.3.0
|
||||
svgwrite==1.4.3
|
||||
tablib==3.2.1
|
||||
tablib==3.3.0
|
||||
tzdata==2022.7
|
||||
|
||||
# Workaround for #7401
|
||||
|
Loading…
Reference in New Issue
Block a user