mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge branch 'develop' into feature
This commit is contained in:
@ -1,13 +1,24 @@
|
||||
# NetBox v2.10
|
||||
|
||||
## v2.10.10 (FUTURE)
|
||||
## v2.10.10 (2021-04-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5796](https://github.com/netbox-community/netbox/issues/5796) - Add DC terminal power port, outlet types
|
||||
* [#5980](https://github.com/netbox-community/netbox/issues/5980) - Add Saf-D-Grid power port, outlet types
|
||||
* [#6157](https://github.com/netbox-community/netbox/issues/6157) - Support Markdown rendering for report logs
|
||||
* [#6160](https://github.com/netbox-community/netbox/issues/6160) - Add F connector port type
|
||||
* [#6168](https://github.com/netbox-community/netbox/issues/6168) - Add SFP56 50GE interface type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5419](https://github.com/netbox-community/netbox/issues/5419) - Update parent device/VM when deleting a primary IP
|
||||
* [#5643](https://github.com/netbox-community/netbox/issues/5643) - Fix VLAN assignment when editing VM interfaces in bulk
|
||||
* [#5652](https://github.com/netbox-community/netbox/issues/5652) - Update object data when renaming a custom field
|
||||
* [#6056](https://github.com/netbox-community/netbox/issues/6056) - Optimize change log cleanup
|
||||
* [#6144](https://github.com/netbox-community/netbox/issues/6144) - Fix MAC address field display in VM interfaces search form
|
||||
* [#6152](https://github.com/netbox-community/netbox/issues/6152) - Fix custom field filtering for cables, virtual chassis
|
||||
* [#6162](https://github.com/netbox-community/netbox/issues/6162) - Fix choice field filters (multiple models)
|
||||
|
||||
---
|
||||
|
||||
|
@ -337,6 +337,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_USB_MICRO_B = 'usb-micro-b'
|
||||
TYPE_USB_3_B = 'usb-3-b'
|
||||
TYPE_USB_3_MICROB = 'usb-3-micro-b'
|
||||
# Direct current (DC)
|
||||
TYPE_DC = 'dc-terminal'
|
||||
# Proprietary
|
||||
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@ -437,6 +441,12 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_USB_3_B, 'USB 3.0 Type B'),
|
||||
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
|
||||
)),
|
||||
('DC', (
|
||||
(TYPE_DC, 'DC Terminal'),
|
||||
)),
|
||||
('Proprietary', (
|
||||
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@ -530,8 +540,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_USB_A = 'usb-a'
|
||||
TYPE_USB_MICROB = 'usb-micro-b'
|
||||
TYPE_USB_C = 'usb-c'
|
||||
# Direct current (DC)
|
||||
TYPE_DC = 'dc-terminal'
|
||||
# Proprietary
|
||||
TYPE_HDOT_CX = 'hdot-cx'
|
||||
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@ -625,8 +638,12 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_USB_MICROB, 'USB Micro B'),
|
||||
(TYPE_USB_C, 'USB Type C'),
|
||||
)),
|
||||
('DC', (
|
||||
(TYPE_DC, 'DC Terminal'),
|
||||
)),
|
||||
('Proprietary', (
|
||||
(TYPE_HDOT_CX, 'HDOT Cx'),
|
||||
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||
)),
|
||||
)
|
||||
|
||||
@ -668,6 +685,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_10GE_XENPAK = '10gbase-x-xenpak'
|
||||
TYPE_10GE_X2 = '10gbase-x-x2'
|
||||
TYPE_25GE_SFP28 = '25gbase-x-sfp28'
|
||||
TYPE_50GE_SFP56 = '50gbase-x-sfp56'
|
||||
TYPE_40GE_QSFP_PLUS = '40gbase-x-qsfpp'
|
||||
TYPE_50GE_QSFP28 = '50gbase-x-sfp28'
|
||||
TYPE_100GE_CFP = '100gbase-x-cfp'
|
||||
@ -772,6 +790,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
|
||||
(TYPE_10GE_X2, 'X2 (10GE)'),
|
||||
(TYPE_25GE_SFP28, 'SFP28 (25GE)'),
|
||||
(TYPE_50GE_SFP56, 'SFP56 (50GE)'),
|
||||
(TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'),
|
||||
(TYPE_50GE_QSFP28, 'QSFP28 (50GE)'),
|
||||
(TYPE_100GE_CFP, 'CFP (100GE)'),
|
||||
@ -904,6 +923,7 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_TERA1P = 'tera-1p'
|
||||
TYPE_110_PUNCH = '110-punch'
|
||||
TYPE_BNC = 'bnc'
|
||||
TYPE_F = 'f'
|
||||
TYPE_MRJ21 = 'mrj21'
|
||||
TYPE_ST = 'st'
|
||||
TYPE_SC = 'sc'
|
||||
@ -933,6 +953,7 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_TERA1P, 'TERA 1P'),
|
||||
(TYPE_110_PUNCH, '110 Punch'),
|
||||
(TYPE_BNC, 'BNC'),
|
||||
(TYPE_F, 'F Connector'),
|
||||
(TYPE_MRJ21, 'MRJ21'),
|
||||
),
|
||||
),
|
||||
|
@ -498,6 +498,10 @@ class PowerPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
feed_leg = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
@ -505,6 +509,10 @@ class PowerOutletTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
@ -512,6 +520,10 @@ class InterfaceTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
@ -519,6 +531,10 @@ class FrontPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
|
||||
|
||||
class RearPortTemplateFilterSet(BaseFilterSet, DeviceTypeComponentFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
@ -895,6 +911,10 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina
|
||||
choices=PowerOutletTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
feed_leg = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
@ -1000,6 +1020,10 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
|
||||
|
||||
class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
@ -1007,6 +1031,10 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati
|
||||
|
||||
|
||||
class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=PortTypeChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
@ -1423,6 +1451,10 @@ class PowerFeedFilterSet(
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack (ID)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=PowerFeedStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
|
@ -940,9 +940,8 @@ class PowerOutletTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_feed_leg(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class InterfaceTemplateTestCase(TestCase):
|
||||
@ -981,9 +980,8 @@ class InterfaceTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'type': InterfaceTypeChoices.TYPE_1GE_FIXED}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mgmt_only(self):
|
||||
params = {'mgmt_only': 'true'}
|
||||
@ -1035,9 +1033,8 @@ class FrontPortTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RearPortTemplateTestCase(TestCase):
|
||||
@ -1076,9 +1073,8 @@ class RearPortTemplateTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_positions(self):
|
||||
params = {'positions': [1, 2]}
|
||||
@ -1985,9 +1981,8 @@ class PowerOutletTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_feed_leg(self):
|
||||
# TODO: Support filtering for multiple values
|
||||
params = {'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_A}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
@ -2284,9 +2279,8 @@ class FrontPortTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['First', 'Second']}
|
||||
@ -2396,9 +2390,8 @@ class RearPortTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'type': PortTypeChoices.TYPE_8P8C}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'type': [PortTypeChoices.TYPE_8P8C, PortTypeChoices.TYPE_110_PUNCH]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_positions(self):
|
||||
params = {'positions': [1, 2]}
|
||||
@ -3045,9 +3038,8 @@ class PowerFeedTestCase(TestCase):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
# TODO: Test for multiple values
|
||||
params = {'status': PowerFeedStatusChoices.STATUS_ACTIVE}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'status': [PowerFeedStatusChoices.STATUS_ACTIVE, PowerFeedStatusChoices.STATUS_FAILED]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
params = {'type': PowerFeedTypeChoices.TYPE_PRIMARY}
|
||||
|
@ -114,6 +114,24 @@ class CustomField(BigIDModel):
|
||||
def __str__(self):
|
||||
return self.label or self.name.replace('_', ' ').capitalize()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache instance's original name so we can check later whether it has changed
|
||||
self._name = self.name
|
||||
|
||||
def rename_object_data(self, old_name, new_name):
|
||||
"""
|
||||
Called when a CustomField has been renamed. Updates all assigned object data.
|
||||
"""
|
||||
for ct in self.content_types.all():
|
||||
model = ct.model_class()
|
||||
params = {f'custom_field_data__{old_name}__isnull': False}
|
||||
instances = model.objects.filter(**params)
|
||||
for instance in instances:
|
||||
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
def remove_stale_data(self, content_types):
|
||||
"""
|
||||
Delete custom field data which is no longer relevant (either because the CustomField is
|
||||
|
@ -5,7 +5,7 @@ from cacheops.signals import cache_invalidated, cache_read
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.db.models.signals import m2m_changed, pre_delete
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.utils import timezone
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from prometheus_client import Counter
|
||||
@ -98,6 +98,14 @@ def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
|
||||
instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
|
||||
|
||||
|
||||
def handle_cf_renamed(instance, created, **kwargs):
|
||||
"""
|
||||
Handle the renaming of custom field data on objects when a CustomField is renamed.
|
||||
"""
|
||||
if not created and instance.name != instance._name:
|
||||
instance.rename_object_data(old_name=instance._name, new_name=instance.name)
|
||||
|
||||
|
||||
def handle_cf_deleted(instance, **kwargs):
|
||||
"""
|
||||
Handle the cleanup of old custom field data when a CustomField is deleted.
|
||||
@ -106,6 +114,7 @@ def handle_cf_deleted(instance, **kwargs):
|
||||
|
||||
|
||||
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
|
||||
post_save.connect(handle_cf_renamed, sender=CustomField)
|
||||
pre_delete.connect(handle_cf_deleted, sender=CustomField)
|
||||
|
||||
|
||||
|
@ -91,6 +91,33 @@ class CustomFieldTest(TestCase):
|
||||
# Delete the custom field
|
||||
cf.delete()
|
||||
|
||||
def test_rename_customfield(self):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
FIELD_DATA = 'abc'
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='field1')
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Assign custom field data to an object
|
||||
site = Site.objects.create(
|
||||
name='Site 1',
|
||||
slug='site-1',
|
||||
custom_field_data={'field1': FIELD_DATA}
|
||||
)
|
||||
site.refresh_from_db()
|
||||
self.assertEqual(site.custom_field_data['field1'], FIELD_DATA)
|
||||
|
||||
# Rename the custom field
|
||||
cf.name = 'field2'
|
||||
cf.save()
|
||||
|
||||
# Check that custom field data on the object has been updated
|
||||
site.refresh_from_db()
|
||||
self.assertNotIn('field1', site.custom_field_data)
|
||||
self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
|
||||
|
||||
|
||||
class CustomFieldManagerTest(TestCase):
|
||||
|
||||
|
@ -66,9 +66,11 @@
|
||||
<a href="{{ url }}">{{ obj }}</a>
|
||||
{% elif obj %}
|
||||
{{ obj }}
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ message }}</td>
|
||||
<td class="rendered-markdown">{{ message|render_markdown }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
@ -789,14 +789,36 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
|
||||
if 'virtual_machine' in self.initial:
|
||||
vm_id = self.initial.get('virtual_machine')
|
||||
|
||||
# Restrict parent interface assignment by VM
|
||||
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
|
||||
# Restrict parent interface assignment by VM
|
||||
self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
|
||||
|
||||
# Limit VLAN choices by virtual machine
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
# Limit VLAN choices by virtual machine
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
|
||||
|
||||
else:
|
||||
# See 5643
|
||||
if 'pk' in self.initial:
|
||||
site = None
|
||||
interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
|
||||
'virtual_machine__cluster__site'
|
||||
)
|
||||
|
||||
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||
# loop or reset back to no site and break the loop.
|
||||
for interface in interfaces:
|
||||
if site is None:
|
||||
site = interface.virtual_machine.cluster.site
|
||||
elif interface.virtual_machine.cluster.site is not site:
|
||||
site = None
|
||||
break
|
||||
|
||||
if site is not None:
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
||||
|
||||
|
||||
class VMInterfaceBulkRenameForm(BulkRenameForm):
|
||||
|
Reference in New Issue
Block a user