1
0
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:
jeremystretch
2021-04-15 15:46:48 -04:00
9 changed files with 167 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -66,9 +66,11 @@
<a href="{{ url }}">{{ obj }}</a>
{% elif obj %}
{{ obj }}
{% else %}
<span class="muted">&mdash;</span>
{% endif %}
</td>
<td>{{ message }}</td>
<td class="rendered-markdown">{{ message|render_markdown }}</td>
</tr>
{% endfor %}
{% endfor %}

View File

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