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 0f4e7cf7e3..4910e794d4 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 d5335947a8..079927ea35 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 df9af0f191..3b4959a1e0 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 0000000000..22843c490c --- /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 61e6106cd3..161d65e326 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 31c5b957d7..c8ca1daf19 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 6ed7b349fc..f0c7f31cb9 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 152d8b7265..a364d3c6af 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 f0c7f31cb9..25f07c3bd1 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 25f07c3bd1..980a4ea756 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 3b4959a1e0..d1950bf2d2 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 d1950bf2d2..51e0c5b26c 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 d490e8fe92..8e2b014774 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 079927ea35..da791001c5 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 da791001c5..0f27479063 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 980a4ea756..023d3a83f9 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 f5f5ed7bfd..c3064e7cd7 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 70eb4b6596..b7020d6638 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 b7020d6638..4104bd2063 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 1760d4d2e0..5fddf825c8 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 5fddf825c8..9a6a7ae7b9 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 8e2b014774..059bb0bd7e 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 11e5fb3f50..d57d0a59b4 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 8480c97bf2..273ee6570e 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 9a6a7ae7b9..6b626d9929 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 c05aa31dfa..f3b1269f9f 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 2622a14050..57e8b1c790 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 57a682c94b..79804aabd5 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 1958718134..58ad98e8fe 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 850cb63885..0b593289bc 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 eee7fe1ed9..988aa1b6df 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 023d3a83f9..bcf0f6e799 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 bcf0f6e799..8d50db9585 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 4104bd2063..e17f946828 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 6b626d9929..1dadb3ebae 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 ab730410e1..2a7003b8d2 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 1dadb3ebae..ddd4c24883 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 8749dc63f3..a2286efed1 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 ddd4c24883..665bfb99e9 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 c8ca1daf19..1d3677cce9 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 665bfb99e9..7b9a9e4b2b 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 cb8c940b00..40f8918ae6 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 e97ade7d87..0ec6d439d0 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 25ad1415de..0f015b7f34 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 c3064e7cd7..2da9daee75 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 cab95bb02b..92c4bb0aa2 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 e5a1c8488b..e6368cb745 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 84522480fa..fa3c73e124 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 244bcee8e3..475ad787e6 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 ba5583a2e9..801b977669 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 5577d90e05..8f18423be8 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 c9f87105dc..a0c98425a2 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 d5017eb537..89dbdf901b 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 1d3677cce9..81c798c714 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 81c798c714..cd0be30967 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 a3b182da1f..9a0609c123 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 7b9a9e4b2b..eac616a2ce 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 813c946a34..7fcab6ba33 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 5c7d229558..22537abe06 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 0000000000..b389dd2b3e --- /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 53c589bb37..7839dc03ec 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 4bb72dce25..198f9d62d7 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 eac616a2ce..6eadb3d9e2 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 45844b0491..38bf16f0b3 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 cd0be30967..1798932196 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 647abe1480..92658d3104 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