From 69a1cc8759bd1d574ceb2046affcbf2fa8a62150 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Fri, 15 Apr 2022 20:36:40 +0000 Subject: [PATCH 01/37] Closes #8998: Add site group filter to racks --- netbox/dcim/filtersets.py | 13 +++++++++++++ netbox/dcim/forms/filtersets.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0f4e7cf7e..4910e794d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -346,6 +346,19 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='rack__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='rack__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) location_id = TreeNodeMultipleChoiceFilter( queryset=Location.objects.all(), field_name='rack__location', diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d5335947a..079927ea3 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -210,7 +210,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte model = Rack fieldsets = ( (None, ('q', 'tag')), - ('Location', ('region_id', 'site_id', 'location_id')), + ('Location', ('region_id', 'site_id', 'site_group_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -229,6 +229,11 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte }, label=_('Site') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.all(), required=False, @@ -282,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('User', ('user_id',)), - ('Rack', ('region_id', 'site_id', 'location_id')), + ('Rack', ('region_id', 'site_id', 'site_group_id', 'location_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) region_id = DynamicModelMultipleChoiceField( @@ -298,6 +303,11 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): }, label=_('Site') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) location_id = DynamicModelMultipleChoiceField( queryset=Location.objects.prefetch_related('site'), required=False, From bc2491e6b767c929435c51776bf793e32b7b1d7b Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Fri, 15 Apr 2022 21:50:24 +0000 Subject: [PATCH 02/37] Closes #8894: Add first and last name to APISelect widget if set --- netbox/users/api/nested_serializers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index df9af0f19..3b4959a1e 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -23,11 +23,17 @@ class NestedGroupSerializer(WritableNestedSerializer): class NestedUserSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') + display = serializers.SerializerMethodField(read_only=True) class Meta: model = User fields = ['id', 'url', 'display', 'username'] + def get_display(self, obj): + if obj.first_name and obj.last_name: + return f"{obj.username} ({obj.first_name} {obj.last_name})" + return obj.username + class NestedTokenSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') From 7b5625a722a9b3e69636ffe3a89b9d314a1ce8e3 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Fri, 29 Apr 2022 09:19:19 +0200 Subject: [PATCH 03/37] Add management command for clearing cache --- netbox/extras/management/commands/clearcache.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 netbox/extras/management/commands/clearcache.py diff --git a/netbox/extras/management/commands/clearcache.py b/netbox/extras/management/commands/clearcache.py new file mode 100644 index 000000000..22843c490 --- /dev/null +++ b/netbox/extras/management/commands/clearcache.py @@ -0,0 +1,11 @@ +from django.core.cache import cache +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Command to clear the entire cache.""" + help = 'Clears the cache.' + + def handle(self, *args, **kwargs): + cache.clear() + self.stdout.write('Cache has been cleared.', ending="\n") From 9f3846ec5f3d9f8fdb0b9758e00d81b0df623989 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Fri, 29 Apr 2022 09:19:37 +0200 Subject: [PATCH 04/37] Clear the cache when running the upgrade script --- upgrade.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/upgrade.sh b/upgrade.sh index 61e6106cd..161d65e32 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -108,6 +108,11 @@ COMMAND="python3 netbox/manage.py clearsessions" echo "Removing expired user sessions ($COMMAND)..." eval $COMMAND || exit 1 +# Clear the cache +COMMAND="python3 netbox/manage.py clearcache" +echo "Clearing the cache ($COMMAND)..." +eval $COMMAND || exit 1 + if [ -v WARN_MISSING_VENV ]; then echo "--------------------------------------------------------------------" echo "WARNING: No existing virtual environment was detected. A new one has" From 3fb967b482a8239da8b8932f7795bd7f49adc47b Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Sat, 30 Apr 2022 02:19:11 +0200 Subject: [PATCH 05/37] Add ability to adopt components when adding a module --- netbox/dcim/forms/models.py | 15 +++++++++-- netbox/dcim/models/devices.py | 51 ++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 31c5b957d..c8ca1daf1 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -633,12 +633,18 @@ class ModuleForm(NetBoxModelForm): help_text="Automatically populate components associated with this module type" ) + adopt_components = forms.BooleanField( + required=False, + initial=False, + help_text="Adopt already existing components" + ) + fieldsets = ( ('Module', ( 'device', 'module_bay', 'manufacturer', 'module_type', 'tags', )), ('Hardware', ( - 'serial', 'asset_tag', 'replicate_components', + 'serial', 'asset_tag', 'replicate_components', 'adopt_components', )), ) @@ -646,7 +652,7 @@ class ModuleForm(NetBoxModelForm): model = Module fields = [ 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', - 'replicate_components', 'comments', + 'replicate_components', 'adopt_components', 'comments', ] def __init__(self, *args, **kwargs): @@ -655,6 +661,8 @@ class ModuleForm(NetBoxModelForm): if self.instance.pk: self.fields['replicate_components'].initial = False self.fields['replicate_components'].disabled = True + self.fields['adopt_components'].initial = False + self.fields['adopt_components'].disabled = True def save(self, *args, **kwargs): @@ -662,6 +670,9 @@ class ModuleForm(NetBoxModelForm): 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) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 6ed7b349f..f0c7f31cb 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1065,31 +1065,38 @@ class Module(NetBoxModel, ConfigContextModel): super().save(*args, **kwargs) + adopt_components = getattr(self, '_adopt_components', False) + disable_replication = getattr(self, '_disable_replication', False) + # If this is a new Module and component replication has not been disabled, instantiate all its # related components per the ModuleType definition - if is_new and not getattr(self, '_disable_replication', False): - ConsolePort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()] - ) - ConsoleServerPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()] - ) - PowerPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()] - ) - PowerOutlet.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()] - ) - Interface.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()] - ) - RearPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()] - ) - FrontPort.objects.bulk_create( - [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()] - ) + if is_new and not disable_replication: + # Iterate all component templates + for templates, component_attribute in [ + ("consoleporttemplates", "consoleports"), + ("consoleserverporttemplates", "consoleserverports"), + ("interfacetemplates", "interfaces"), + ("powerporttemplates", "powerports"), + ("poweroutlettemplates", "poweroutlets"), + ("rearporttemplates", "rearports"), + ("frontporttemplates", "frontports") + ]: + # Get the template for the module type. + for template in getattr(self.module_type, templates).all(): + template_instance = template.instantiate(device=self.device, module=self) + if adopt_components: + existing_item = getattr(self.device, component_attribute).filter(name=template_instance.name).first() + + # Check if there's a component with the same name already + if existing_item: + # Assign it to the module + existing_item.module = self + existing_item.save() + continue + + # If we are not adopting components or the component doesn't already exist + template_instance.save() # # Virtual chassis From 30d4097fd8e3173c1f8f1df3fbaa61c2700b2816 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Mon, 2 May 2022 12:09:49 +0200 Subject: [PATCH 06/37] Fix early terminated tuple in IPAddressRoleChoices --- netbox/ipam/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 152d8b726..a364d3c6a 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -91,7 +91,7 @@ class IPAddressRoleChoices(ChoiceSet): (ROLE_VRRP, 'VRRP', 'green'), (ROLE_HSRP, 'HSRP', 'green'), (ROLE_GLBP, 'GLBP', 'green'), - (ROLE_CARP, 'CARP'), 'green', + (ROLE_CARP, 'CARP', 'green'), ) From c2a6a1c125fd4c2a286552c08529ebddf0bfc57c Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 2 May 2022 21:37:37 +0200 Subject: [PATCH 07/37] Create module components in bulk --- netbox/dcim/models/devices.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f0c7f31cb..25f07c3bd 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1072,15 +1072,18 @@ class Module(NetBoxModel, ConfigContextModel): # related components per the ModuleType definition if is_new and not disable_replication: # Iterate all component templates - for templates, component_attribute in [ - ("consoleporttemplates", "consoleports"), - ("consoleserverporttemplates", "consoleserverports"), - ("interfacetemplates", "interfaces"), - ("powerporttemplates", "powerports"), - ("poweroutlettemplates", "poweroutlets"), - ("rearporttemplates", "rearports"), - ("frontporttemplates", "frontports") + for templates, component_attribute, component_model in [ + ("consoleporttemplates", "consoleports", ConsolePort), + ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), + ("interfacetemplates", "interfaces", Interface), + ("powerporttemplates", "powerports", PowerPort), + ("poweroutlettemplates", "poweroutlets", PowerOutlet), + ("rearporttemplates", "rearports", RearPort), + ("frontporttemplates", "frontports", FrontPort) ]: + create_instances = [] + update_instances = [] + # Get the template for the module type. for template in getattr(self.module_type, templates).all(): template_instance = template.instantiate(device=self.device, module=self) @@ -1092,11 +1095,15 @@ class Module(NetBoxModel, ConfigContextModel): if existing_item: # Assign it to the module existing_item.module = self - existing_item.save() + update_instances.append(existing_item) continue # If we are not adopting components or the component doesn't already exist - template_instance.save() + create_instances.append(template_instance) + + component_model.objects.bulk_create(create_instances) + component_model.objects.bulk_update(update_instances, ['module']) + # # Virtual chassis From 977ccb01f2f5d6407f0edfd29a4b64f7bd70b086 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Mon, 2 May 2022 21:55:34 +0200 Subject: [PATCH 08/37] Formatting: Remove whitespace on blank line --- netbox/dcim/models/devices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 25f07c3bd..980a4ea75 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1097,10 +1097,10 @@ class Module(NetBoxModel, ConfigContextModel): existing_item.module = self update_instances.append(existing_item) continue - + # If we are not adopting components or the component doesn't already exist create_instances.append(template_instance) - + component_model.objects.bulk_create(create_instances) component_model.objects.bulk_update(update_instances, ['module']) From 25c266e4de70a20b43c70b7b1d81f407b47555ce Mon Sep 17 00:00:00 2001 From: minitriga Date: Tue, 3 May 2022 09:00:52 +0100 Subject: [PATCH 09/37] Update netbox/users/api/nested_serializers.py Co-authored-by: Jeremy Stretch --- netbox/users/api/nested_serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 3b4959a1e..d1950bf2d 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -30,8 +30,8 @@ class NestedUserSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'username'] def get_display(self, obj): - if obj.first_name and obj.last_name: - return f"{obj.username} ({obj.first_name} {obj.last_name})" + if full_name := obj.get_full_name(): + return f"{obj.username} ({full_name})" return obj.username From 535606a1852525e328f0ee220be0c3fa28fcde02 Mon Sep 17 00:00:00 2001 From: minitriga Date: Tue, 3 May 2022 09:01:06 +0100 Subject: [PATCH 10/37] Update netbox/users/api/nested_serializers.py Co-authored-by: Jeremy Stretch --- netbox/users/api/nested_serializers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index d1950bf2d..51e0c5b26 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -23,7 +23,6 @@ class NestedGroupSerializer(WritableNestedSerializer): class NestedUserSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') - display = serializers.SerializerMethodField(read_only=True) class Meta: model = User From 0a9ba3b2e6ee2c711ca09c56a2772a8f7957f0e8 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Tue, 3 May 2022 10:45:08 +0000 Subject: [PATCH 11/37] add get_display to users serializer --- netbox/users/api/serializers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index d490e8fe9..8e2b01477 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -44,6 +44,11 @@ class UserSerializer(ValidatedModelSerializer): user.save() return user + + def get_display(self, obj): + if full_name := obj.get_full_name(): + return f"{obj.username} ({full_name})" + return obj.username class GroupSerializer(ValidatedModelSerializer): From 15e91908e8b169dd38f051fa8b45c868d26103f5 Mon Sep 17 00:00:00 2001 From: minitriga Date: Tue, 3 May 2022 11:47:32 +0100 Subject: [PATCH 12/37] Update netbox/dcim/forms/filtersets.py Co-authored-by: Jeremy Stretch --- netbox/dcim/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 079927ea3..da791001c 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -210,7 +210,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte model = Rack fieldsets = ( (None, ('q', 'tag')), - ('Location', ('region_id', 'site_id', 'site_group_id', 'location_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), From 7cd840610b7fe718932574f4a9a2226075d2dd44 Mon Sep 17 00:00:00 2001 From: minitriga Date: Tue, 3 May 2022 11:47:37 +0100 Subject: [PATCH 13/37] Update netbox/dcim/forms/filtersets.py Co-authored-by: Jeremy Stretch --- netbox/dcim/forms/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index da791001c..0f2747906 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -287,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('User', ('user_id',)), - ('Rack', ('region_id', 'site_id', 'site_group_id', 'location_id')), + ('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) region_id = DynamicModelMultipleChoiceField( From 8040804c753d070b386b41b650ec53bc10d08e26 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Tue, 3 May 2022 22:03:12 +0200 Subject: [PATCH 14/37] Allow mixture of component replication and adoption --- netbox/dcim/models/devices.py | 61 ++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 980a4ea75..023d3a83f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1068,41 +1068,44 @@ class Module(NetBoxModel, ConfigContextModel): adopt_components = getattr(self, '_adopt_components', False) disable_replication = getattr(self, '_disable_replication', False) - # If this is a new Module and component replication has not been disabled, instantiate all its - # related components per the ModuleType definition - if is_new and not disable_replication: - # Iterate all component templates - for templates, component_attribute, component_model in [ - ("consoleporttemplates", "consoleports", ConsolePort), - ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), - ("interfacetemplates", "interfaces", Interface), - ("powerporttemplates", "powerports", PowerPort), - ("poweroutlettemplates", "poweroutlets", PowerOutlet), - ("rearporttemplates", "rearports", RearPort), - ("frontporttemplates", "frontports", FrontPort) - ]: - create_instances = [] - update_instances = [] + # We skip adding components if the module is being edited or + # both replication and component adoption is disabled + if not is_new or (disable_replication and not adopt_components): + return - # Get the template for the module type. - for template in getattr(self.module_type, templates).all(): - template_instance = template.instantiate(device=self.device, module=self) + # Iterate all component types + for templates, component_attribute, component_model in [ + ("consoleporttemplates", "consoleports", ConsolePort), + ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort), + ("interfacetemplates", "interfaces", Interface), + ("powerporttemplates", "powerports", PowerPort), + ("poweroutlettemplates", "poweroutlets", PowerOutlet), + ("rearporttemplates", "rearports", RearPort), + ("frontporttemplates", "frontports", FrontPort) + ]: + create_instances = [] + update_instances = [] - if adopt_components: - existing_item = getattr(self.device, component_attribute).filter(name=template_instance.name).first() + # Get the template for the module type. + for template in getattr(self.module_type, templates).all(): + template_instance = template.instantiate(device=self.device, module=self) - # Check if there's a component with the same name already - if existing_item: - # Assign it to the module - existing_item.module = self - update_instances.append(existing_item) - continue + if adopt_components: + existing_item = getattr(self.device, component_attribute).filter(name=template_instance.name).first() - # If we are not adopting components or the component doesn't already exist + # Check if there's a component with the same name already + if existing_item: + # Assign it to the module + existing_item.module = self + update_instances.append(existing_item) + continue + + # Only create new components if replication is enabled + if not disable_replication: create_instances.append(template_instance) - component_model.objects.bulk_create(create_instances) - component_model.objects.bulk_update(update_instances, ['module']) + component_model.objects.bulk_create(create_instances) + component_model.objects.bulk_update(update_instances, ['module']) # From bdaefc0e4d6f9cc179028ba913741f2cc155b1c7 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 3 May 2022 18:34:32 -0400 Subject: [PATCH 15/37] Closes #9278: Linkify device type in manufacturer table --- netbox/dcim/tables/devicetypes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index f5f5ed7bf..c3064e7cd 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -31,7 +31,9 @@ class ManufacturerTable(NetBoxTable): name = tables.Column( linkify=True ) - devicetype_count = tables.Column( + devicetype_count = columns.LinkedCountColumn( + viewname='dcim:devicetype_list', + url_params={'manufacturer_id': 'pk'}, verbose_name='Device Types' ) inventoryitem_count = tables.Column( From f455f91ea3eeb38b1480ef30e6521157b783c782 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Wed, 4 May 2022 08:58:42 +0200 Subject: [PATCH 16/37] Add view test for module component adoption --- netbox/dcim/tests/test_views.py | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 70eb4b659..b7020d663 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1869,6 +1869,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_component_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 a module with replicated components + form_data['module_bay'] = ModuleBay.objects.filter(device=device)[0] + form_data['replicate_components'] = True + request = { + 'path': self._get_url('add'), + 'data': post_data(form_data), + } + self.assertHttpStatus(self.client.post(**request), 302) + + # Check that the interface was created + initial_interface = Interface.objects.filter(device=device, name=interface_name).first() + self.assertIsNotNone(initial_interface) + + # Save the module id associated with the interface + initial_module_id = initial_interface.module.id + + # Create a second module (in the next bay) with adopted components + # The module id of the interface should change + form_data['module_bay'] = ModuleBay.objects.filter(device=device)[1] + form_data['replicate_components'] = False + form_data['adopt_components'] = True + request = { + 'path': self._get_url('add'), + 'data': post_data(form_data), + } + + self.assertHttpStatus(self.client.post(**request), 302) + + # Re-retrieve interface to get new module id + initial_interface.refresh_from_db() + updated_module_id = initial_interface.module.id + + # Check that the module id has changed + self.assertNotEqual(initial_module_id, updated_module_id) + class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort From 7de27c69c054f382bf1baa68be9558476bab53fd Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Wed, 4 May 2022 09:16:19 +0200 Subject: [PATCH 17/37] Fix PEP8 --- netbox/dcim/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b7020d663..4104bd206 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1894,7 +1894,7 @@ class ModuleTestCase( # Check that the interface was created initial_interface = Interface.objects.filter(device=device, name=interface_name).first() self.assertIsNotNone(initial_interface) - + # Save the module id associated with the interface initial_module_id = initial_interface.module.id From eab187fb6be652ff82c27400360a64f3684e34dc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 4 May 2022 13:59:38 -0400 Subject: [PATCH 18/37] Changelog for #9267, #9278 --- docs/release-notes/version-3.2.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 1760d4d2e..5fddf825c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -2,6 +2,14 @@ ## v3.2.3 (FUTURE) +### Enhancements + +* [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list + +### Bug Fixes + +* [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices + --- ## v3.2.2 (2022-04-28) From da1aabdfc1b40dd81c402c6005232b1e3db86beb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 4 May 2022 14:19:09 -0400 Subject: [PATCH 19/37] Changelog for #8894, #8998, #9122; PEP8 fix --- docs/release-notes/version-3.2.md | 3 +++ netbox/users/api/serializers.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 5fddf825c..9a6a7ae7b 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -4,6 +4,9 @@ ### Enhancements +* [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users +* [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group +* [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list ### Bug Fixes diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 8e2b01477..059bb0bd7 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -44,7 +44,7 @@ class UserSerializer(ValidatedModelSerializer): user.save() return user - + def get_display(self, obj): if full_name := obj.get_full_name(): return f"{obj.username} ({full_name})" From 015bc48345caa8aae4bd01995830b3cd02101843 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 4 May 2022 14:29:36 -0400 Subject: [PATCH 20/37] #8998: Add region filter for rack reservations; Add filter tests --- netbox/dcim/filtersets.py | 13 ++++++++++ netbox/dcim/tests/test_filtersets.py | 36 +++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 11e5fb3f5..d57d0a59b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -346,6 +346,19 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='rack__site__region', + lookup_expr='in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='rack__site__region', + lookup_expr='in', + to_field_name='slug', + label='Region (slug)', + ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), field_name='rack__site__group', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 8480c97bf..273ee6570 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -521,10 +521,26 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1'), - Site(name='Site 2', slug='site-2'), - Site(name='Site 3', slug='site-3'), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -572,6 +588,20 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): ) RackReservation.objects.bulk_create(reservations) + def test_region(self): + regions = Region.objects.all()[:2] + params = {'region_id': [regions[0].pk, regions[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': [regions[0].slug, regions[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} From 0301aec409fbf29834d3a4cfbecb481a60ec6b8a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 4 May 2022 15:46:13 -0400 Subject: [PATCH 21/37] Closes #9260: Apply user preferences to tables under object detail views --- docs/release-notes/version-3.2.md | 1 + netbox/circuits/views.py | 6 +++--- netbox/dcim/views.py | 16 +++++++++------- netbox/ipam/views.py | 10 +++++----- netbox/tenancy/views.py | 8 ++++---- netbox/virtualization/views.py | 4 ++-- netbox/wireless/views.py | 4 ++-- 7 files changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 9a6a7ae7b..6b626d992 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -7,6 +7,7 @@ * [#8894](https://github.com/netbox-community/netbox/issues/8894) - Include full names when listing users * [#8998](https://github.com/netbox-community/netbox/issues/8998) - Enable filtering racks & reservations by site group * [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade +* [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list ### Bug Fixes diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index c05aa31df..f3b1269f9 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -32,7 +32,7 @@ class ProviderView(generic.ObjectView): ).prefetch_related( 'type', 'tenant', 'terminations__site' ) - circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) + circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',)) circuits_table.configure(request) return { @@ -93,7 +93,7 @@ class ProviderNetworkView(generic.ObjectView): ).prefetch_related( 'type', 'tenant', 'terminations__site' ) - circuits_table = tables.CircuitTable(circuits) + circuits_table = tables.CircuitTable(circuits, user=request.user) circuits_table.configure(request) return { @@ -147,7 +147,7 @@ class CircuitTypeView(generic.ObjectView): def get_extra_context(self, request, instance): circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) - circuits_table = tables.CircuitTable(circuits, exclude=('type',)) + circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('type',)) circuits_table.configure(request) return { diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2622a1405..57e8b1c79 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -166,7 +166,7 @@ class RegionView(generic.ObjectView): sites = Site.objects.restrict(request.user, 'view').filter( region=instance ) - sites_table = tables.SiteTable(sites, exclude=('region',)) + sites_table = tables.SiteTable(sites, user=request.user, exclude=('region',)) sites_table.configure(request) return { @@ -251,7 +251,7 @@ class SiteGroupView(generic.ObjectView): sites = Site.objects.restrict(request.user, 'view').filter( group=instance ) - sites_table = tables.SiteTable(sites, exclude=('group',)) + sites_table = tables.SiteTable(sites, user=request.user, exclude=('group',)) sites_table.configure(request) return { @@ -435,7 +435,7 @@ class LocationView(generic.ObjectView): 'rack_count', cumulative=True ).filter(pk__in=location_ids).exclude(pk=instance.pk) - child_locations_table = tables.LocationTable(child_locations) + child_locations_table = tables.LocationTable(child_locations, user=request.user) child_locations_table.configure(request) nonracked_devices = Device.objects.filter( @@ -514,7 +514,9 @@ class RackRoleView(generic.ObjectView): role=instance ) - racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) + racks_table = tables.RackTable(racks, user=request.user, exclude=( + 'role', 'get_utilization', 'get_power_utilization', + )) racks_table.configure(request) return { @@ -767,7 +769,7 @@ class ManufacturerView(generic.ObjectView): manufacturer=instance ) - devicetypes_table = tables.DeviceTypeTable(device_types, exclude=('manufacturer',)) + devicetypes_table = tables.DeviceTypeTable(device_types, user=request.user, exclude=('manufacturer',)) devicetypes_table.configure(request) return { @@ -1480,7 +1482,7 @@ class DeviceRoleView(generic.ObjectView): devices = Device.objects.restrict(request.user, 'view').filter( device_role=instance ) - devices_table = tables.DeviceTable(devices, exclude=('device_role',)) + devices_table = tables.DeviceTable(devices, user=request.user, exclude=('device_role',)) devices_table.configure(request) return { @@ -1544,7 +1546,7 @@ class PlatformView(generic.ObjectView): devices = Device.objects.restrict(request.user, 'view').filter( platform=instance ) - devices_table = tables.DeviceTable(devices, exclude=('platform',)) + devices_table = tables.DeviceTable(devices, user=request.user, exclude=('platform',)) devices_table.configure(request) return { diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 57a682c94..79804aabd 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -161,7 +161,7 @@ class RIRView(generic.ObjectView): aggregates = Aggregate.objects.restrict(request.user, 'view').filter(rir=instance).annotate( child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) ) - aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) + aggregates_table = tables.AggregateTable(aggregates, user=request.user, exclude=('rir', 'utilization')) aggregates_table.configure(request) return { @@ -221,12 +221,12 @@ class ASNView(generic.ObjectView): def get_extra_context(self, request, instance): # Gather assigned Sites sites = instance.sites.restrict(request.user, 'view') - sites_table = SiteTable(sites) + sites_table = SiteTable(sites, user=request.user) sites_table.configure(request) # Gather assigned Providers providers = instance.providers.restrict(request.user, 'view') - providers_table = ProviderTable(providers) + providers_table = ProviderTable(providers, user=request.user) providers_table.configure(request) return { @@ -366,7 +366,7 @@ class RoleView(generic.ObjectView): role=instance ) - prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) + prefixes_table = tables.PrefixTable(prefixes, user=request.user, exclude=('role', 'utilization')) prefixes_table.configure(request) return { @@ -805,7 +805,7 @@ class VLANGroupView(generic.ObjectView): vlans_count = vlans.count() vlans = add_available_vlans(vlans, vlan_group=instance) - vlans_table = tables.VLANTable(vlans, exclude=('group',)) + vlans_table = tables.VLANTable(vlans, user=request.user, exclude=('group',)) if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): vlans_table.columns.show('pk') vlans_table.configure(request) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 195871813..58ad98e8f 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -35,7 +35,7 @@ class TenantGroupView(generic.ObjectView): tenants = Tenant.objects.restrict(request.user, 'view').filter( group=instance ) - tenants_table = tables.TenantTable(tenants, exclude=('group',)) + tenants_table = tables.TenantTable(tenants, user=request.user, exclude=('group',)) tenants_table.configure(request) return { @@ -184,7 +184,7 @@ class ContactGroupView(generic.ObjectView): contacts = Contact.objects.restrict(request.user, 'view').filter( group=instance ) - contacts_table = tables.ContactTable(contacts, exclude=('group',)) + contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',)) contacts_table.configure(request) return { @@ -250,7 +250,7 @@ class ContactRoleView(generic.ObjectView): contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( role=instance ) - contacts_table = tables.ContactAssignmentTable(contact_assignments) + contacts_table = tables.ContactAssignmentTable(contact_assignments, user=request.user) contacts_table.columns.hide('role') contacts_table.configure(request) @@ -307,7 +307,7 @@ class ContactView(generic.ObjectView): contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter( contact=instance ) - assignments_table = tables.ContactAssignmentTable(contact_assignments) + assignments_table = tables.ContactAssignmentTable(contact_assignments, user=request.user) assignments_table.columns.hide('contact') assignments_table.configure(request) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 850cb6388..0b593289b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -39,7 +39,7 @@ class ClusterTypeView(generic.ObjectView): device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ) - clusters_table = tables.ClusterTable(clusters, exclude=('type',)) + clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('type',)) clusters_table.configure(request) return { @@ -101,7 +101,7 @@ class ClusterGroupView(generic.ObjectView): device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ) - clusters_table = tables.ClusterTable(clusters, exclude=('group',)) + clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('group',)) clusters_table.configure(request) return { diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index eee7fe1ed..988aa1b6d 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -29,7 +29,7 @@ class WirelessLANGroupView(generic.ObjectView): wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter( group=instance ) - wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) + wirelesslans_table = tables.WirelessLANTable(wirelesslans, user=request.user, exclude=('group',)) wirelesslans_table.configure(request) return { @@ -97,7 +97,7 @@ class WirelessLANView(generic.ObjectView): attached_interfaces = Interface.objects.restrict(request.user, 'view').filter( wireless_lans=instance ) - interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) + interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces, user=request.user) interfaces_table.configure(request) return { From 81c7fe2084b59dcfc16c821f661119bd95adf6f0 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 4 May 2022 22:59:28 +0200 Subject: [PATCH 22/37] Don't adopt components already belonging to a module --- netbox/dcim/models/devices.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 023d3a83f..bcf0f6e79 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1091,7 +1091,8 @@ class Module(NetBoxModel, ConfigContextModel): template_instance = template.instantiate(device=self.device, module=self) if adopt_components: - existing_item = getattr(self.device, component_attribute).filter(name=template_instance.name).first() + existing_item = getattr(self.device, component_attribute).filter( + module__isnull=True, name=template_instance.name).first() # Check if there's a component with the same name already if existing_item: From c52aa2196df72f30553c1610905dd3a5b0745982 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Wed, 4 May 2022 23:21:03 +0200 Subject: [PATCH 23/37] Prefetch installed components when adding modules --- netbox/dcim/models/devices.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index bcf0f6e79..8d50db958 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1086,13 +1086,17 @@ class Module(NetBoxModel, ConfigContextModel): create_instances = [] update_instances = [] + # Prefetch installed components + installed_components = { + component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True) + } + # Get the template for the module type. for template in getattr(self.module_type, templates).all(): template_instance = template.instantiate(device=self.device, module=self) if adopt_components: - existing_item = getattr(self.device, component_attribute).filter( - module__isnull=True, name=template_instance.name).first() + existing_item = installed_components.get(template_instance.name) # Check if there's a component with the same name already if existing_item: From 9c3dfdfd14fe4321bbcdc1b642ea79fd2e176a60 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 5 May 2022 09:30:13 +0200 Subject: [PATCH 24/37] Fix test_module_component_adoption --- netbox/dcim/tests/test_views.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 4104bd206..e17f94682 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1882,25 +1882,16 @@ class ModuleTestCase( form_data = self.form_data.copy() device = Device.objects.get(pk=form_data['device']) - # Create a module with replicated components - form_data['module_bay'] = ModuleBay.objects.filter(device=device)[0] - form_data['replicate_components'] = True - request = { - 'path': self._get_url('add'), - 'data': post_data(form_data), - } - self.assertHttpStatus(self.client.post(**request), 302) + # Create an interface to be adopted + interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED) + interface.save() - # Check that the interface was created - initial_interface = Interface.objects.filter(device=device, name=interface_name).first() - self.assertIsNotNone(initial_interface) + # Ensure that interface is created with no module + self.assertIsNone(interface.module) - # Save the module id associated with the interface - initial_module_id = initial_interface.module.id - - # Create a second module (in the next bay) with adopted components - # The module id of the interface should change - form_data['module_bay'] = ModuleBay.objects.filter(device=device)[1] + # Create a module with adopted components + form_data['module_bay'] = ModuleBay.objects.filter(device=device).first() + form_data['module_type'] = module_type form_data['replicate_components'] = False form_data['adopt_components'] = True request = { @@ -1911,11 +1902,10 @@ class ModuleTestCase( self.assertHttpStatus(self.client.post(**request), 302) # Re-retrieve interface to get new module id - initial_interface.refresh_from_db() - updated_module_id = initial_interface.module.id + interface.refresh_from_db() - # Check that the module id has changed - self.assertNotEqual(initial_module_id, updated_module_id) + # Check that the Interface now has a module + self.assertIsNotNone(interface.module) class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): From bddca8e2321b9fb1930f0e69d556c13fbeaf0e1c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 5 May 2022 14:14:49 -0400 Subject: [PATCH 25/37] Changelog for #9280 --- docs/release-notes/version-3.2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 6b626d992..1dadb3eba 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -9,6 +9,7 @@ * [#9122](https://github.com/netbox-community/netbox/issues/9122) - Introduce `clearcache` management command & clear cache during upgrade * [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list +* [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module ### Bug Fixes From 13584693757b4c90fc9b190799e15f1bce47c813 Mon Sep 17 00:00:00 2001 From: kkthxbye <> Date: Fri, 6 May 2022 08:01:15 +0200 Subject: [PATCH 26/37] Remove stray characters from Config Context tab --- netbox/templates/extras/object_configcontext.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html index ab730410e..2a7003b8d 100644 --- a/netbox/templates/extras/object_configcontext.html +++ b/netbox/templates/extras/object_configcontext.html @@ -43,7 +43,7 @@
{{ context.weight }}
- {{ context|linkify:"name" }}"> + {{ context|linkify:"name" }} {% if context.description %}
{{ context.description }} {% endif %} From 422ec7ecec81bb55c9c81874bb6e8dedbec58986 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 6 May 2022 09:25:40 -0400 Subject: [PATCH 27/37] Fixes #9311: Permit creating contact assignment without a priority via the REST API --- docs/release-notes/version-3.2.md | 1 + netbox/tenancy/api/serializers.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 1dadb3eba..ddd4c2488 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,7 @@ ### Bug Fixes * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices +* [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API --- diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 8749dc63f..a2286efed 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -97,7 +97,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer): object = serializers.SerializerMethodField(read_only=True) contact = NestedContactSerializer() role = NestedContactRoleSerializer(required=False, allow_null=True) - priority = ChoiceField(choices=ContactPriorityChoices, required=False) + priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default='') class Meta: model = ContactAssignment From 9b4e016fe40f71f81dc8992b4fa379e722ac1b93 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 6 May 2022 09:47:52 -0400 Subject: [PATCH 28/37] Fixes #9306: Include VC master interfaces when selecting a LAG/bridge for a VC member interface --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/forms/models.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index ddd4c2488..665bfb99e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,7 @@ ### Bug Fixes * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices +* [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API --- diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index c8ca1daf1..1d3677cce 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1295,6 +1295,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'rf_channel_width': "Populated by selected channel (if set)", } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Restrict LAG/bridge interface assignment by device/VC + device_id = self.data['device'] if self.is_bound else self.initial.get('device') + device = Device.objects.filter(pk=device_id).first() + if device and device.virtual_chassis and device.virtual_chassis.master: + self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + class FrontPortForm(NetBoxModelForm): module = DynamicModelChoiceField( From 39a9ebaeee982dc787de5df3b42f66ac9fbe39d4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 6 May 2022 10:26:02 -0400 Subject: [PATCH 29/37] Fixes #9313: Remove HTML code from CSV output of many-to-many relationships --- docs/release-notes/version-3.2.md | 1 + netbox/circuits/tables/circuits.py | 2 +- netbox/circuits/tables/providers.py | 4 +- netbox/dcim/tables/devices.py | 2 +- netbox/dcim/tables/devicetypes.py | 2 +- netbox/dcim/tables/power.py | 2 +- netbox/dcim/tables/racks.py | 2 +- netbox/dcim/tables/sites.py | 10 +-- netbox/ipam/tables/ip.py | 2 +- netbox/netbox/tables/columns.py | 62 ++++++++++++------- netbox/tenancy/tables/tenants.py | 2 +- netbox/virtualization/tables/clusters.py | 4 +- .../virtualization/tables/virtualmachines.py | 2 +- 13 files changed, 57 insertions(+), 40 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 665bfb99e..7b9a9e4b2 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -16,6 +16,7 @@ * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices * [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API +* [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships --- diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index cb8c940b0..40f8918ae 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -59,7 +59,7 @@ class CircuitTable(NetBoxTable): ) commit_rate = CommitRateColumn() comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py index e97ade7d8..0ec6d439d 100644 --- a/netbox/circuits/tables/providers.py +++ b/netbox/circuits/tables/providers.py @@ -14,7 +14,7 @@ class ProviderTable(NetBoxTable): name = tables.Column( linkify=True ) - asns = tables.ManyToManyColumn( + asns = columns.ManyToManyColumn( linkify_item=True, verbose_name='ASNs' ) @@ -31,7 +31,7 @@ class ProviderTable(NetBoxTable): verbose_name='Circuits' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 25ad1415d..0f015b7f3 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -190,7 +190,7 @@ class DeviceTable(NetBoxTable): verbose_name='VC Priority' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index c3064e7cd..2da9daee7 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -43,7 +43,7 @@ class ManufacturerTable(NetBoxTable): verbose_name='Platforms' ) slug = tables.Column() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index cab95bb02..92c4bb0aa 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -26,7 +26,7 @@ class PowerPanelTable(NetBoxTable): url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index e5a1c8488..e6368cb74 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -69,7 +69,7 @@ class RackTable(NetBoxTable): orderable=False, verbose_name='Power' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 84522480f..fa3c73e12 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -26,7 +26,7 @@ class RegionTable(NetBoxTable): url_params={'region_id': 'pk'}, verbose_name='Sites' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -55,7 +55,7 @@ class SiteGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Sites' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -86,7 +86,7 @@ class SiteTable(NetBoxTable): group = tables.Column( linkify=True ) - asns = tables.ManyToManyColumn( + asns = columns.ManyToManyColumn( linkify_item=True, verbose_name='ASNs' ) @@ -98,7 +98,7 @@ class SiteTable(NetBoxTable): ) tenant = TenantColumn() comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -137,7 +137,7 @@ class LocationTable(NetBoxTable): url_params={'location_id': 'pk'}, verbose_name='Devices' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 244bcee8e..475ad787e 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -118,7 +118,7 @@ class ASNTable(NetBoxTable): url_params={'asn_id': 'pk'}, verbose_name='Provider Count' ) - sites = tables.ManyToManyColumn( + sites = columns.ManyToManyColumn( linkify_item=True, verbose_name='Sites' ) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index ba5583a2e..801b97766 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -6,7 +6,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.db.models import DateField, DateTimeField from django.template import Context, Template -from django.urls import NoReverseMatch, reverse +from django.urls import reverse from django.utils.formats import date_format from django.utils.safestring import mark_safe from django_tables2.columns import library @@ -27,6 +27,7 @@ __all__ = ( 'CustomLinkColumn', 'LinkedCountColumn', 'MarkdownColumn', + 'ManyToManyColumn', 'MPTTColumn', 'TagColumn', 'TemplateColumn', @@ -35,6 +36,10 @@ __all__ = ( ) +# +# Django-tables2 overrides +# + @library.register class DateColumn(tables.DateColumn): """ @@ -42,7 +47,6 @@ 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 value(self, value): return value @@ -59,7 +63,6 @@ class DateTimeColumn(tables.DateTimeColumn): 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 DateTimeField. """ - def value(self, value): if value: return date_format(value, format="SHORT_DATETIME_FORMAT") @@ -71,6 +74,39 @@ class DateTimeColumn(tables.DateTimeColumn): return cls(**kwargs) +class ManyToManyColumn(tables.ManyToManyColumn): + """ + Overrides django-tables2's stock ManyToManyColumn to ensure that value() returns only plaintext data. + """ + def value(self, value): + items = [self.transform(item) for item in self.filter(value)] + return self.separator.join(items) + + +class TemplateColumn(tables.TemplateColumn): + """ + Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value + is an empty string. + """ + PLACEHOLDER = mark_safe('—') + + def render(self, *args, **kwargs): + ret = super().render(*args, **kwargs) + if not ret.strip(): + return self.PLACEHOLDER + return ret + + def value(self, **kwargs): + ret = super().value(**kwargs) + if ret == self.PLACEHOLDER: + return '' + return ret + + +# +# Custom columns +# + class ToggleColumn(tables.CheckBoxColumn): """ Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. @@ -112,26 +148,6 @@ class BooleanColumn(tables.Column): return str(value) -class TemplateColumn(tables.TemplateColumn): - """ - Overrides django-tables2's stock TemplateColumn class to render a placeholder symbol if the returned value - is an empty string. - """ - PLACEHOLDER = mark_safe('—') - - def render(self, *args, **kwargs): - ret = super().render(*args, **kwargs) - if not ret.strip(): - return self.PLACEHOLDER - return ret - - def value(self, **kwargs): - ret = super().value(**kwargs) - if ret == self.PLACEHOLDER: - return '' - return ret - - @dataclass class ActionsItem: title: str diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index 5577d90e0..8f18423be 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/netbox/tenancy/tables/tenants.py @@ -38,7 +38,7 @@ class TenantTable(NetBoxTable): linkify=True ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index c9f87105d..a0c98425a 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -40,7 +40,7 @@ class ClusterGroupTable(NetBoxTable): url_params={'group_id': 'pk'}, verbose_name='Clusters' ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( @@ -83,7 +83,7 @@ class ClusterTable(NetBoxTable): verbose_name='VMs' ) comments = columns.MarkdownColumn() - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index d5017eb53..89dbdf901 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -78,7 +78,7 @@ class VMInterfaceTable(BaseInterfaceTable): vrf = tables.Column( linkify=True ) - contacts = tables.ManyToManyColumn( + contacts = columns.ManyToManyColumn( linkify_item=True ) tags = columns.TagColumn( From af126fe7e353c259f867956601b6eb2183e91bf6 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Tue, 10 May 2022 17:50:33 +0200 Subject: [PATCH 30/37] Added form validation to model installation Raises a ValidationError whenever installation would cause a foreign key violation. --- netbox/dcim/forms/models.py | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 1d3677cce..81c798c71 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -675,6 +675,56 @@ class ModuleForm(NetBoxModelForm): 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}' 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}', 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 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): From d858eceb387f65ec96d297def940205cddee7bf3 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Tue, 10 May 2022 17:53:01 +0200 Subject: [PATCH 31/37] Fix pep8 --- netbox/dcim/forms/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 81c798c71..cd0be3096 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -718,7 +718,7 @@ class ModuleForm(NetBoxModelForm): raise forms.ValidationError( f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs 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( From e759e123ac5f143d75ce636eee8e8f82f1387f6d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 08:09:51 -0400 Subject: [PATCH 32/37] Fixes #9333: Annotate unit on interface speed field --- netbox/dcim/models/device_components.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a3b182da1..9a0609c12 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -543,7 +543,8 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo ) speed = models.PositiveIntegerField( blank=True, - null=True + null=True, + verbose_name='Speed (Kbps)' ) duplex = models.CharField( max_length=50, From bdb21da26e06a764237199ef63325af9aec0bd92 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 08:57:19 -0400 Subject: [PATCH 33/37] Fixes #9330: Add missing module_type field to REST API serializers for modular device component templates --- docs/release-notes/version-3.2.md | 1 + netbox/dcim/api/serializers.py | 103 ++++++++++++++++++++++++------ netbox/dcim/tests/test_api.py | 98 +++++++++++++++++++++++----- 3 files changed, 167 insertions(+), 35 deletions(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 7b9a9e4b2..eac616a2c 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -17,6 +17,7 @@ * [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API * [#9313](https://github.com/netbox-community/netbox/issues/9313) - Remove HTML code from CSV output of many-to-many relationships +* [#9330](https://github.com/netbox-community/netbox/issues/9330) - Add missing `module_type` field to REST API serializers for modular device component templates --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 813c946a3..7fcab6ba3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -315,7 +315,16 @@ class ModuleTypeSerializer(NetBoxModelSerializer): class ConsolePortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -325,13 +334,23 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', ] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -341,13 +360,23 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created', + 'last_updated', ] class PowerPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, @@ -357,14 +386,23 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', - 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', + 'allocated_draw', 'description', 'created', 'last_updated', ] class PowerOutletTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, @@ -383,48 +421,75 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'created', 'last_updated', ] class InterfaceTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=InterfaceTypeChoices) class Meta: model = InterfaceTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', - 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'created', 'last_updated', ] class RearPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=PortTypeChoices) class Meta: model = RearPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', - 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', + 'description', 'created', 'last_updated', ] class FrontPortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') - device_type = NestedDeviceTypeSerializer() + device_type = NestedDeviceTypeSerializer( + required=False, + allow_null=True, + default=None + ) + module_type = NestedModuleTypeSerializer( + required=False, + allow_null=True, + default=None + ) type = ChoiceField(choices=PortTypeChoices) rear_port = NestedRearPortTemplateSerializer() class Meta: model = FrontPortTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'created', 'last_updated', ] diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 5c7d22955..22537abe0 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -523,6 +523,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) console_port_templates = ( ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'), @@ -541,9 +544,13 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Console Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Console Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Console Port Template 7', + }, ] @@ -560,6 +567,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) console_server_port_templates = ( ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'), @@ -578,9 +588,13 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Console Server Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Console Server Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Console Server Port Template 7', + }, ] @@ -597,6 +611,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) power_port_templates = ( PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), @@ -615,9 +632,13 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Power Port Template 5', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Power Port Template 6', }, + { + 'module_type': moduletype.pk, + 'name': 'Power Port Template 7', + }, ] @@ -634,6 +655,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) power_port_templates = ( PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'), @@ -664,6 +688,14 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): 'name': 'Power Outlet Template 6', 'power_port': None, }, + { + 'module_type': moduletype.pk, + 'name': 'Power Outlet Template 7', + }, + { + 'module_type': moduletype.pk, + 'name': 'Power Outlet Template 8', + }, ] @@ -680,6 +712,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) interface_templates = ( InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'), @@ -700,10 +735,15 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): 'type': '1000base-t', }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Interface Template 6', 'type': '1000base-t', }, + { + 'module_type': moduletype.pk, + 'name': 'Interface Template 7', + 'type': '1000base-t', + }, ] @@ -720,14 +760,19 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) rear_port_templates = ( RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 2', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 3', type=PortTypeChoices.TYPE_8P8C), RearPortTemplate(device_type=devicetype, name='Rear Port Template 4', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(device_type=devicetype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), - RearPortTemplate(device_type=devicetype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 5', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 6', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 7', type=PortTypeChoices.TYPE_8P8C), + RearPortTemplate(module_type=moduletype, name='Rear Port Template 8', type=PortTypeChoices.TYPE_8P8C), ) RearPortTemplate.objects.bulk_create(rear_port_templates) @@ -745,15 +790,28 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): rear_port=rear_port_templates[1] ), FrontPortTemplate( - device_type=devicetype, - name='Front Port Template 3', + module_type=moduletype, + name='Front Port Template 5', type=PortTypeChoices.TYPE_8P8C, - rear_port=rear_port_templates[2] + rear_port=rear_port_templates[4] + ), + FrontPortTemplate( + module_type=moduletype, + name='Front Port Template 6', + type=PortTypeChoices.TYPE_8P8C, + rear_port=rear_port_templates[5] ), ) FrontPortTemplate.objects.bulk_create(front_port_templates) cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Front Port Template 3', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rear_port_templates[2].pk, + 'rear_port_position': 1, + }, { 'device_type': devicetype.pk, 'name': 'Front Port Template 4', @@ -762,17 +820,17 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): 'rear_port_position': 1, }, { - 'device_type': devicetype.pk, - 'name': 'Front Port Template 5', + 'module_type': moduletype.pk, + 'name': 'Front Port Template 7', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[4].pk, + 'rear_port': rear_port_templates[6].pk, 'rear_port_position': 1, }, { - 'device_type': devicetype.pk, - 'name': 'Front Port Template 6', + 'module_type': moduletype.pk, + 'name': 'Front Port Template 8', 'type': PortTypeChoices.TYPE_8P8C, - 'rear_port': rear_port_templates[5].pk, + 'rear_port': rear_port_templates[7].pk, 'rear_port_position': 1, }, ] @@ -791,6 +849,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): devicetype = DeviceType.objects.create( manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' ) + moduletype = ModuleType.objects.create( + manufacturer=manufacturer, model='Module Type 1' + ) rear_port_templates = ( RearPortTemplate(device_type=devicetype, name='Rear Port Template 1', type=PortTypeChoices.TYPE_8P8C), @@ -811,10 +872,15 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): 'type': PortTypeChoices.TYPE_8P8C, }, { - 'device_type': devicetype.pk, + 'module_type': moduletype.pk, 'name': 'Rear Port Template 6', 'type': PortTypeChoices.TYPE_8P8C, }, + { + 'module_type': moduletype.pk, + 'name': 'Rear Port Template 7', + 'type': PortTypeChoices.TYPE_8P8C, + }, ] From 22f186347518cbed8a5b8ecd4063872543b7c8c6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 09:12:07 -0400 Subject: [PATCH 34/37] Add security document --- SECURITY.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..b389dd2b3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## No Warranty + +Per the terms of the Apache 2 license, NetBox is offered "as is" and without any guarantee or warranty pertaining to its operation. While every reasonable effort is made by its maintainers to ensure the product remains free of security vulnerabilities, users are ultimately responsible for conducting their own evaluations of each software release. + +## Recommendations + +Administrators are encouraged to adhere to industry best practices concerning the secure operation of software, such as: + +* Do not expose your NetBox installation to the public Internet +* Do not permit multiple users to share an account +* Enforce minimum password complexity requirements for local accounts +* Prohibit access to your database from clients other than the NetBox application +* Keep your deployment updated to the most recent stable release + +## Reporting a Suspected Vulnerability + +If you believe you've uncovered a security vulnerability and wish to report it confidentially, you may do so via email. Please note that any reported vulnerabilities **MUST** meet all the following conditions: + +* Affects the most recent stable release of NetBox, or a current beta release +* Affects a NetBox instance installed and configured per the official documentation +* Is reproducible following a prescribed set of instructions + +Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous. + +If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project. + +### Bug Bounties + +As NetBox is provided as free open source software, we do not offer any monetary compensation for vulnerability or bug reports, however your contributions are greatly appreciated. From cffc064a33498340ab5d5f9e5f3082591a92d9f5 Mon Sep 17 00:00:00 2001 From: devon-mar Date: Wed, 11 May 2022 07:27:50 -0700 Subject: [PATCH 35/37] Add device & vm to `FHRPGroupAssignmentFilterSet` (#9314) * Add device & vm to `FHRPGroupAssignmentFilterSet` * Apply suggestions from code review * Update netbox/ipam/tests/test_filtersets.py * Update netbox/ipam/filtersets.py Co-authored-by: Jeremy Stretch --- netbox/ipam/filtersets.py | 42 ++++++++++++++++++++++++++++ netbox/ipam/tests/test_filtersets.py | 14 ++++++++++ 2 files changed, 56 insertions(+) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 53c589bb3..7839dc03e 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -681,11 +681,53 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): queryset=FHRPGroup.objects.all(), label='Group (ID)', ) + device = MultiValueCharFilter( + method='filter_device', + field_name='name', + label='Device (name)', + ) + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', + label='Device (ID)', + ) + virtual_machine = MultiValueCharFilter( + method='filter_virtual_machine', + field_name='name', + label='Virtual machine (name)', + ) + virtual_machine_id = MultiValueNumberFilter( + method='filter_virtual_machine', + field_name='pk', + label='Virtual machine (ID)', + ) class Meta: model = FHRPGroupAssignment fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] + def filter_device(self, queryset, name, value): + devices = Device.objects.filter(**{f'{name}__in': value}) + if not devices.exists(): + return queryset.none() + interface_ids = [] + for device in devices: + interface_ids.extend(device.vc_interfaces().values_list('id', flat=True)) + return queryset.filter( + Q(interface_type=ContentType.objects.get_for_model(Interface), interface_id__in=interface_ids) + ) + + def filter_virtual_machine(self, queryset, name, value): + virtual_machines = VirtualMachine.objects.filter(**{f'{name}__in': value}) + if not virtual_machines.exists(): + return queryset.none() + interface_ids = [] + for vm in virtual_machines: + interface_ids.extend(vm.interfaces.values_list('id', flat=True)) + return queryset.filter( + Q(interface_type=ContentType.objects.get_for_model(VMInterface), interface_id__in=interface_ids) + ) + class VLANGroupFilterSet(OrganizationalModelFilterSet): scope_type = ContentTypeFilter() diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 4bb72dce2..198f9d62d 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1024,6 +1024,20 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'priority': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_device(self): + device = Device.objects.first() + params = {'device': [device.name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'device_id': [device.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_virtual_machine(self): + vm = VirtualMachine.objects.first() + params = {'virtual_machine': [vm.name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'virtual_machine_id': [vm.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLANGroup.objects.all() From e8575495dbddd9e65e9e17b84b1ae3faed645985 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 10:31:04 -0400 Subject: [PATCH 36/37] Changelog for #9190, #9314 --- docs/release-notes/version-3.2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index eac616a2c..6eadb3d9e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -10,9 +10,11 @@ * [#9260](https://github.com/netbox-community/netbox/issues/9260) - Apply user preferences to tables under object detail views * [#9278](https://github.com/netbox-community/netbox/issues/9278) - Linkify device types count under manufacturers list * [#9280](https://github.com/netbox-community/netbox/issues/9280) - Allow adopting existing components when installing a module +* [#9314](https://github.com/netbox-community/netbox/issues/9314) - Add device and VM filters for FHRP group assignments ### Bug Fixes +* [#9190](https://github.com/netbox-community/netbox/issues/9190) - Prevent exception when attempting to instantiate module components which already exist on the parent device * [#9267](https://github.com/netbox-community/netbox/issues/9267) - Remove invalid entry in IP address role choices * [#9306](https://github.com/netbox-community/netbox/issues/9306) - Include VC master interfaces when selecting a LAG/bridge for a VC member interface * [#9311](https://github.com/netbox-community/netbox/issues/9311) - Permit creating contact assignment without a priority via the REST API From 1726593fb00f9e393322fb6c25ea6a0f48d53ee9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 11 May 2022 10:37:04 -0400 Subject: [PATCH 37/37] Introduce MODULE_TOKEN constant --- netbox/dcim/constants.py | 2 ++ netbox/dcim/forms/models.py | 7 ++++--- netbox/dcim/models/device_component_templates.py | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 45844b049..38bf16f0b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -62,6 +62,8 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage # Device components # +MODULE_TOKEN = '{module}' + MODULAR_COMPONENT_TEMPLATE_MODELS = Q( app_label='dcim', model__in=( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index cd0be3096..179893219 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -705,18 +705,19 @@ class ModuleForm(NetBoxModelForm): # 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}' in template.name and not module_bay.position: + 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}', module_bay.position) + 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 to a module" + 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 diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 647abe148..92658d310 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -121,12 +121,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel): def resolve_name(self, module): if module: - return self.name.replace('{module}', module.module_bay.position) + return self.name.replace(MODULE_TOKEN, module.module_bay.position) return self.name def resolve_label(self, module): if module: - return self.label.replace('{module}', module.module_bay.position) + return self.label.replace(MODULE_TOKEN, module.module_bay.position) return self.label