From 0cae4793b35623c036c4d7d06f611139e57988e0 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 ece17b829c609cc26a746ecbc8d9e9b5cab86d39 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 ca8fef5a038448ef1e1846d0fa17f57467156f11 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 931b794c032761de6a66f8a079bcacc272151ea9 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 a55ff798da1c4b9f89fdd2828a0ae224199d99c4 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 a45a6874c5a9d592154f442ca2d9997636d940c0 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 e93913f91c639dcafa585c547d9d0268224615ac 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 4c74cbc060cac69b6394dc3fa2883d53ad813173 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 e5f20dec8d44ada5b3fe16d15973a42a093c95c3 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 f621bf739e445091d343395f17b721da75c1a9e1 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 fc4aff20186bd380f75812a39bd81b46fcd64ea9 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 e009b047c6ef2e737b6faccde763f17a985c499e 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 b8acf8d56b8cd75e2df011002bc2901ee8ac6613 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 02358baef2f8e4f0ec407114b505d72fc87ef131 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 c574b9d2f44f56d2fa7c7af771d20c3e257cbea0 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 e49c5c820056b404817fc50b725ba342e7b32df8 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 0c181614cdc29ab2caeae5dc1f679cdfeeb0bc73 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 6fc395d4f5abde30afd33653035ce43af1b618bc 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 c8b34d779478c7d6b2bd0edf96991bce708469d3 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 612df4061d5eed1a72838edbcfb6e29cd5fe126b 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 919b2a1565e2c97df69b32ac64e2ce0d6a52693c 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 3a772237716d8edae47e4074aaa618c368d6fd92 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 36d1b1bcb8340f75a5f6531e1afc81f3add7d72e 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 a6d087c964b67037258432d69c9d41bd8be94a9c 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 16abd147b56d638088b828ef0044f4f381d51431 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 02cd66dfd344adf5eb9a3f393f76e628d43f7c50 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 e8cfefad09657e28106973d7f6d92d14ac91dbc7 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 b647c8b112c2ba344d6fe52d906a27d0de0501c5 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 75b25f060e9784ec7865665910e87a0878b84647 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 fe1dc847cc0fe89aaa0033a5a073e6382ae3b531 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 04256b36e8458bd0a774dbcedb6f69d102523bd2 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 a7944e4da222c201c432f63afada37dc18f32838 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 e421c352d6b4887d8c1e3baf639363085c0bd51e 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 ab7244ae49f495a386acedb1a98b8323d7100bf1 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 25fc04ce28a59f8484d98bd60047f99c12a4690d 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 59765df58a4a60ba5b86d1997f9de120ef46a337 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 3402c673a6987309ae6be73bd134d78a4bb113c6 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