Compare commits

...

25 Commits

Author SHA1 Message Date
Jeremy Stretch
fb27803ab0 Merge pull request #11174 from netbox-community/develop
Release v3.3.10
2022-12-13 15:44:42 -05:00
jeremystretch
5e32b39f25 Release v3.3.10 2022-12-13 15:29:07 -05:00
jeremystretch
b9888d6f86 Fixes #11109: Fix nullification of custom object & multi-object fields via REST API 2022-12-13 14:48:40 -05:00
jeremystretch
96a796ebde Fixes #11173: Enable missing tags columns for contact, L2VPN lists 2022-12-13 14:04:50 -05:00
jeremystretch
996e73d5d8 Fixes #10981: Fix release notes formatting 2022-12-13 13:26:41 -05:00
jeremystretch
5c969a8caf Changelog for #9361, #10447, #11077 2022-12-13 13:24:07 -05:00
jeremystretch
68faab8196 Fixes #11168: Honor RQ_DEFAULT_TIMEOUT config parameter when using Redis Sentinel 2022-12-13 13:22:28 -05:00
sleepinggenius2
b3693099dc Adds replication and adoption for module import (#9498)
* Adds replication and adoption for module import

* Moves common Module form clean logic to new class

* Adds tests for replication and adoption for module import

* Fix test

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-13 11:33:09 -05:00
Arthur Hanson
9bb9ac3dec 11077 use formatting for custom field date (#11143)
* 11077 use formatting for custom field date

* Apply configured date format to column render() method

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-13 09:22:57 -05:00
kkthxbye-code
a57378e780 Add missing newline and change wording of InventoryItem validation 2022-12-13 08:58:57 -05:00
kkthxbye-code
41f631b65b Allow re-assigning inventoryitems to other devices 2022-12-13 08:58:57 -05:00
jeremystretch
860805ba82 Closes #10255: Introduce LOGOUT_REDIRECT_URL config parameter to control redirection of user after logout 2022-12-09 17:08:07 -05:00
jeremystretch
1e0b024609 Closes #10516: Add vertical frame & cabinet rack types 2022-12-09 16:35:37 -05:00
jeremystretch
8486d47d17 Fixes #11142: Correct available choices for status under IP range filter form 2022-12-09 16:04:46 -05:00
jeremystretch
407365888a Closes #11089: Permit whitespace in MAC addresses 2022-12-09 16:00:11 -05:00
jeremystretch
ab9c253310 Fixes #11128: Disable ordering changelog table by object to avoid exception 2022-12-08 09:00:02 -05:00
jeremystretch
35596ddcbc Closes #10806: Add warning to run deactivate prior to upgrade script 2022-12-08 09:00:02 -05:00
kkthxbye-code
0cacac82ee Disable sorting by object_repr on ObjectChangeTable 2022-12-08 08:44:11 -05:00
Jeremy Stretch
780997a568 Closes #11119: Enable filtering L2VPNs by slug 2022-12-06 15:48:22 -05:00
Jeremy Stretch
d2d60c0607 Fixes #11087: Fix background color of bottom banner content 2022-12-06 15:40:59 -05:00
Renato Almeida de Oliveira
d4d8d00d01 add distinct method to circuit_count 2022-12-06 15:19:35 -05:00
jeremystretch
db7590df1a Changelog for #10748, #11041 2022-12-02 09:30:44 -05:00
PieterL75
ee03f3d584 10748 Add 'Provider' to the circuit termination edit/view (#10939)
* Show the Provider of the NetworkProvider

* Clean up form fields

Co-authored-by: Pieter Lambrecht <pieter.lambrecht@sentia.com>
Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-02 09:27:47 -05:00
Arthur
826a1714c3 11041 return power percentage with 1 decimal place 2022-12-01 15:41:15 -05:00
jeremystretch
fb407e9076 PRVB 2022-11-30 16:18:03 -05:00
32 changed files with 318 additions and 100 deletions

View File

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

View File

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

View File

@@ -129,6 +129,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`

View File

@@ -225,6 +225,9 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
* Builds the documentation locally (for offline use)
* Aggregate static resource files on disk
!!! warning
If you still have a Python virtual environment active from a previous installation step, disable it now by running the `deactivate` command. This will avoid errors on systems where `sudo` has been configured to preserve the user's current environment.
```no-highlight
sudo /opt/netbox/upgrade.sh
```

View File

@@ -1,5 +1,31 @@
# NetBox v3.3
## 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
---
## v3.3.9 (2022-11-30)
### Enhancements
@@ -452,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

View File

@@ -158,16 +158,28 @@ class CircuitTerminationForm(NetBoxModelForm):
},
required=False
)
provider_network_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False,
label='Provider',
initial_params={
'networks': 'provider_network'
}
)
provider_network = DynamicModelChoiceField(
queryset=ProviderNetwork.objects.all(),
query_params={
'provider_id': '$provider_network_provider',
},
required=False
)
class Meta:
model = CircuitTermination
fields = [
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'tags',
]
help_texts = {
'port_speed': "Physical circuit speed",

View File

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

View File

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

View File

@@ -13,6 +13,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__ = (
'CableCSVForm',
@@ -407,7 +408,7 @@ class DeviceCSVForm(BaseDeviceCSVForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class ModuleCSVForm(NetBoxModelCSVForm):
class ModuleCSVForm(ModuleCommonForm, NetBoxModelCSVForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -420,11 +421,20 @@ class ModuleCSVForm(NetBoxModelCSVForm):
queryset=ModuleType.objects.all(),
to_field_name='model'
)
replicate_components = forms.BooleanField(
required=False,
help_text="Automatically populate components associated with this module type (default: true)"
)
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', 'comments',
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'replicate_components',
'adopt_components', 'comments',
)
def __init__(self, data=None, *args, **kwargs):
@@ -435,6 +445,13 @@ class ModuleCSVForm(NetBoxModelCSVForm):
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 ChildDeviceCSVForm(BaseDeviceCSVForm):
parent = CSVModelChoiceField(

View File

@@ -5,6 +5,7 @@ from dcim.constants import *
__all__ = (
'InterfaceCommonForm',
'ModuleCommonForm'
)
@@ -47,3 +48,60 @@ 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['device']
module_type = self.cleaned_data['module_type']
module_bay = self.cleaned_data['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"
)

View File

@@ -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',
@@ -657,7 +657,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={
@@ -722,68 +722,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):
@@ -1610,6 +1548,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 = [

View File

@@ -1153,3 +1153,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

View File

@@ -63,7 +63,7 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
model = 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',

View File

@@ -1848,6 +1848,53 @@ 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)
form_data = self.form_data.copy()
device = Device.objects.get(pk=form_data['device'])
# Create a module *without* replicating components
module_bay = ModuleBay.objects.get(pk=form_data['module_bay'])
csv_data = [
"device,module_bay,module_type,replicate_components",
f"{device.name},{module_bay.name},{module_type.model},false"
]
request = {
'path': self._get_url('import'),
'data': {
'csv': '\n'.join(csv_data),
}
}
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)
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(pk=(form_data['module_bay'] + 1))
csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},true"
request = {
'path': self._get_url('import'),
'data': {
'csv': '\n'.join(csv_data),
}
}
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)
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')
@@ -1885,6 +1932,49 @@ 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,replicate_components,adopt_components",
f"{device.name},{module_bay.name},{module_type.model},false,true"
]
request = {
'path': self._get_url('import'),
'data': {
'csv': '\n'.join(csv_data),
}
}
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

View File

@@ -335,7 +335,7 @@ class SiteView(generic.ObjectView):
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(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct().count(),
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
}
locations = Location.objects.add_related_count(

View File

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

View File

@@ -195,7 +195,8 @@ class ObjectChangeTable(NetBoxTable):
object_repr = tables.TemplateColumn(
accessor=tables.A('changed_object'),
template_code=OBJECTCHANGE_OBJECT,
verbose_name='Object'
verbose_name='Object',
orderable=False
)
request_id = tables.TemplateColumn(
template_code=OBJECTCHANGE_REQUEST_ID,

View File

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

View File

@@ -960,7 +960,7 @@ class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class Meta:
model = L2VPN
fields = ['id', 'identifier', 'name', 'type', 'description']
fields = ['id', 'identifier', 'name', 'slug', 'type', 'description']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -250,7 +250,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
null_option='Global'
)
status = MultipleChoiceField(
choices=PrefixStatusChoices,
choices=IPRangeStatusChoices,
required=False
)
role_id = DynamicModelMultipleChoiceField(

View File

@@ -29,14 +29,17 @@ class L2VPNTable(TenancyColumnsMixin, NetBoxTable):
template_code=L2VPN_TARGETS,
orderable=False
)
tags = columns.TagColumn(
url_name='ipam:l2vpn_list'
)
class Meta(NetBoxTable.Meta):
model = L2VPN
fields = (
'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group',
'actions',
'pk', 'name', 'slug', 'identifier', 'type', 'description', 'import_targets', 'export_targets', 'tenant',
'tenant_group', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'identifier', 'type', 'description', 'actions')
default_columns = ('pk', 'name', 'identifier', 'type', 'description')
class L2VPNTerminationTable(NetBoxTable):

View File

@@ -1501,6 +1501,10 @@ class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'name': ['L2VPN 1', 'L2VPN 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['l2vpn-1', 'l2vpn-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_identifier(self):
params = {'identifier': ['65001', '65002']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -149,6 +149,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'

View File

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup
#
VERSION = '3.3.9'
VERSION = '3.3.10'
# Hostname
HOSTNAME = platform.node()
@@ -102,6 +102,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', [])
@@ -625,8 +626,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
@@ -636,12 +635,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 = {
'high': RQ_PARAMS,

View File

@@ -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
@@ -50,6 +51,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
@@ -455,6 +460,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))

View File

@@ -103,14 +103,14 @@ Blocks:
{% block content %}{% endblock %}
</div>
{% endblock %}
{# Bottom banner #}
{% if config.BANNER_BOTTOM %}
<div class="text-center mx-3">
{{ config.BANNER_BOTTOM|safe }}
</div>
{% endif %}
</div>
{% if config.BANNER_BOTTOM %}
<div class="text-center mx-3">
{{ config.BANNER_BOTTOM|safe }}
</div>
{% endif %}
{# BS5 pop-up modals #}
{% block modals %}{% endblock %}

View File

@@ -32,6 +32,7 @@
{% render_field form.site %}
</div>
<div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork">
{% render_field form.provider_network_provider %}
{% render_field form.provider_network %}
</div>
</div>

View File

@@ -81,7 +81,7 @@
{% else %}
<tr>
<td>Provider Network</td>
<td>{{ termination.provider_network|linkify }}</td>
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
</tr>
{% endif %}
<tr>

View File

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

View File

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

View File

@@ -138,7 +138,8 @@ def percentage(x, y):
"""
if x is None or y is None:
return None
return round(x / y * 100)
return round(x / y * 100, 1)
@register.filter()

View File

@@ -1,7 +1,7 @@
bleach==5.0.1
Django==4.0.8
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