From 7022bb7eac74af3f113dbc2dd84a224c0e299f1f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 27 May 2026 12:38:47 -0400 Subject: [PATCH] Closes #22109: Add template object counts to ModuleType representation in REST & GraphQL APIs (#22302) --- docs/models/dcim/moduletype.md | 3 +- netbox/dcim/api/serializers_/devicetypes.py | 15 ++- netbox/dcim/filtersets.py | 15 +++ netbox/dcim/forms/filtersets.py | 9 +- netbox/dcim/graphql/types.py | 8 ++ .../0236_moduletype_component_counts.py | 99 +++++++++++++++++++ netbox/dcim/models/modules.py | 38 ++++++- netbox/dcim/tests/test_filtersets.py | 10 ++ netbox/dcim/tests/test_models.py | 65 ++++++++++++ netbox/dcim/views.py | 16 +-- 10 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 netbox/dcim/migrations/0236_moduletype_component_counts.py diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index dca2b4af4..e2e42620d 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -11,8 +11,9 @@ Similar to [device types](./devicetype.md), each module type can have any of the * Power Outlets * Front pass-through ports * Rear pass-through ports +* Module bays -Note that device bays and module bays may _not_ be added to modules. +Note that device bays may _not_ be added to modules. ## Automatic Component Renaming diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 9df6dddd7..1b3087229 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -101,11 +101,24 @@ class ModuleTypeSerializer(PrimaryModelSerializer): ) module_count = serializers.IntegerField(read_only=True) + # Counter fields + console_port_template_count = serializers.IntegerField(read_only=True) + console_server_port_template_count = serializers.IntegerField(read_only=True) + power_port_template_count = serializers.IntegerField(read_only=True) + power_outlet_template_count = serializers.IntegerField(read_only=True) + interface_template_count = serializers.IntegerField(read_only=True) + front_port_template_count = serializers.IntegerField(read_only=True) + rear_port_template_count = serializers.IntegerField(read_only=True) + module_bay_template_count = serializers.IntegerField(read_only=True) + class Meta: model = ModuleType fields = [ 'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'module_count', + 'created', 'last_updated', 'module_count', 'console_port_template_count', + 'console_server_port_template_count', 'power_port_template_count', 'power_outlet_template_count', + 'interface_template_count', 'front_port_template_count', 'rear_port_template_count', + 'module_bay_template_count', ] brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b0bbb9b50..9ebd2ad5b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -862,6 +862,10 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet): method='_pass_through_ports', label=_('Has pass-through ports'), ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label=_('Has module bays'), + ) class Meta: model = ModuleType @@ -869,6 +873,14 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet): 'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description', # Counters + 'console_port_template_count', + 'console_server_port_template_count', + 'power_port_template_count', + 'power_outlet_template_count', + 'interface_template_count', + 'front_port_template_count', + 'rear_port_template_count', + 'module_bay_template_count', 'module_count', ) @@ -904,6 +916,9 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet): rearporttemplates__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebaytemplates__isnull=value) + class DeviceTypeComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 9759a5df4..0cb86a268 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -710,7 +710,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): ), FieldSet( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', - 'pass_through_ports', name=_('Components') + 'pass_through_ports', 'module_bays', name=_('Components') ), FieldSet('weight', 'weight_unit', name=_('Weight')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), @@ -778,6 +778,13 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + module_bays = forms.NullBooleanField( + required=False, + label=_('Has module bays'), + widget=forms.Select( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) tag = TagFilterField(model) airflow = forms.MultipleChoiceField( label=_('Airflow'), diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8a7006f43..50aa4cb22 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -644,6 +644,14 @@ class ModuleTypeProfileType(PrimaryObjectType): ) class ModuleTypeType(PrimaryObjectType): module_count: BigInt + console_port_template_count: BigInt + console_server_port_template_count: BigInt + power_port_template_count: BigInt + power_outlet_template_count: BigInt + interface_template_count: BigInt + front_port_template_count: BigInt + rear_port_template_count: BigInt + module_bay_template_count: BigInt profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] diff --git a/netbox/dcim/migrations/0236_moduletype_component_counts.py b/netbox/dcim/migrations/0236_moduletype_component_counts.py new file mode 100644 index 000000000..d775fa1f1 --- /dev/null +++ b/netbox/dcim/migrations/0236_moduletype_component_counts.py @@ -0,0 +1,99 @@ +from django.db import migrations +from django.db.models import Count, OuterRef, Subquery + +import utilities.fields + + +def populate_module_type_component_counts(apps, schema_editor): + """ + Populate the component template counter fields for existing ModuleTypes. + """ + ModuleType = apps.get_model('dcim', 'ModuleType') + db_alias = schema_editor.connection.alias + + counters = { + 'console_port_template_count': 'consoleporttemplates', + 'console_server_port_template_count': 'consoleserverporttemplates', + 'power_port_template_count': 'powerporttemplates', + 'power_outlet_template_count': 'poweroutlettemplates', + 'interface_template_count': 'interfacetemplates', + 'front_port_template_count': 'frontporttemplates', + 'rear_port_template_count': 'rearporttemplates', + 'module_bay_template_count': 'modulebaytemplates', + } + + for field_name, related_name in counters.items(): + count_subquery = ( + ModuleType.objects.using(db_alias) + .filter(pk=OuterRef('pk')) + .annotate(_count=Count(related_name)) + .values('_count') + ) + ModuleType.objects.using(db_alias).update(**{field_name: Subquery(count_subquery)}) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0235_cabletermination_circuit_site_cache'), + ] + + operations = [ + migrations.AddField( + model_name='moduletype', + name='console_port_template_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.ConsolePortTemplate' + ), + ), + migrations.AddField( + model_name='moduletype', + name='console_server_port_template_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.ConsoleServerPortTemplate' + ), + ), + migrations.AddField( + model_name='moduletype', + name='front_port_template_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.FrontPortTemplate' + ), + ), + migrations.AddField( + model_name='moduletype', + name='interface_template_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.InterfaceTemplate' + ), + ), + migrations.AddField( + model_name='moduletype', + name='module_bay_template_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.ModuleBayTemplate' + ), + ), + migrations.AddField( + model_name='moduletype', + name='power_outlet_template_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.PowerOutletTemplate' + ), + ), + migrations.AddField( + model_name='moduletype', + name='power_port_template_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.PowerPortTemplate' + ), + ), + migrations.AddField( + model_name='moduletype', + name='rear_port_template_count', + field=utilities.fields.CounterCacheField( + default=0, editable=False, to_field='module_type', to_model='dcim.RearPortTemplate' + ), + ), + migrations.RunPython(populate_module_type_component_counts, migrations.RunPython.noop), + ] diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index 1d678deb0..857945d6c 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -58,8 +58,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): """ A ModuleType represents a hardware element that can be installed within a device and which houses additional components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a - DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It - cannot, however house device bays or module bays. + DeviceType, each ModuleType can have console, power, interface, pass-through port, and module bay templates assigned + to it. It cannot, however, house device bays. """ profile = models.ForeignKey( to='dcim.ModuleTypeProfile', @@ -100,6 +100,40 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): to_field='module_type' ) + # Counter fields + console_port_template_count = CounterCacheField( + to_model='dcim.ConsolePortTemplate', + to_field='module_type' + ) + console_server_port_template_count = CounterCacheField( + to_model='dcim.ConsoleServerPortTemplate', + to_field='module_type' + ) + power_port_template_count = CounterCacheField( + to_model='dcim.PowerPortTemplate', + to_field='module_type' + ) + power_outlet_template_count = CounterCacheField( + to_model='dcim.PowerOutletTemplate', + to_field='module_type' + ) + interface_template_count = CounterCacheField( + to_model='dcim.InterfaceTemplate', + to_field='module_type' + ) + front_port_template_count = CounterCacheField( + to_model='dcim.FrontPortTemplate', + to_field='module_type' + ) + rear_port_template_count = CounterCacheField( + to_model='dcim.RearPortTemplate', + to_field='module_type' + ) + module_bay_template_count = CounterCacheField( + to_model='dcim.ModuleBayTemplate', + to_field='module_type' + ) + clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow') prerequisite_models = ( 'dcim.Manufacturer', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index af2fe2790..007445670 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1707,6 +1707,10 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): PortTemplateMapping(module_type=module_types[0], front_port=front_ports[0], rear_port=rear_ports[0]), PortTemplateMapping(module_type=module_types[1], front_port=front_ports[1], rear_port=rear_ports[1]), ]) + ModuleBayTemplate.objects.bulk_create(( + ModuleBayTemplate(module_type=module_types[0], name='Module Bay 1'), + ModuleBayTemplate(module_type=module_types[1], name='Module Bay 2'), + )) def test_q(self): params = {'q': 'foobar1'} @@ -1767,6 +1771,12 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'pass_through_ports': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_module_bays(self): + params = {'module_bays': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'module_bays': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_weight(self): params = {'weight': [10, 20]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 5462257e1..260615b4e 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -114,6 +114,71 @@ class LocationTestCase(TestCase): self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b) +class DeviceTypeTestCase(TestCase): + + def test_component_template_counts(self): + """ + DeviceType component template counters should track the addition and removal of templates. + """ + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + ) + + # Counters should start at zero + self.assertEqual(device_type.interface_template_count, 0) + self.assertEqual(device_type.console_port_template_count, 0) + self.assertEqual(device_type.module_bay_template_count, 0) + self.assertEqual(device_type.device_bay_template_count, 0) + + # Adding templates should increment the relevant counters + InterfaceTemplate.objects.create(device_type=device_type, name='Interface 1') + InterfaceTemplate.objects.create(device_type=device_type, name='Interface 2') + ConsolePortTemplate.objects.create(device_type=device_type, name='Console 1') + ModuleBayTemplate.objects.create(device_type=device_type, name='Module Bay 1') + DeviceBayTemplate.objects.create(device_type=device_type, name='Device Bay 1') + device_type.refresh_from_db() + self.assertEqual(device_type.interface_template_count, 2) + self.assertEqual(device_type.console_port_template_count, 1) + self.assertEqual(device_type.module_bay_template_count, 1) + self.assertEqual(device_type.device_bay_template_count, 1) + + # Deleting a template should decrement the counter + InterfaceTemplate.objects.get(device_type=device_type, name='Interface 1').delete() + device_type.refresh_from_db() + self.assertEqual(device_type.interface_template_count, 1) + + +class ModuleTypeTestCase(TestCase): + + def test_component_template_counts(self): + """ + ModuleType component template counters should track the addition and removal of templates. + """ + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + + # Counters should start at zero + self.assertEqual(module_type.interface_template_count, 0) + self.assertEqual(module_type.console_port_template_count, 0) + self.assertEqual(module_type.module_bay_template_count, 0) + + # Adding templates should increment the relevant counters + InterfaceTemplate.objects.create(module_type=module_type, name='Interface 1') + InterfaceTemplate.objects.create(module_type=module_type, name='Interface 2') + ConsolePortTemplate.objects.create(module_type=module_type, name='Console 1') + ModuleBayTemplate.objects.create(module_type=module_type, name='Module Bay 1') + module_type.refresh_from_db() + self.assertEqual(module_type.interface_template_count, 2) + self.assertEqual(module_type.console_port_template_count, 1) + self.assertEqual(module_type.module_bay_template_count, 1) + + # Deleting a template should decrement the counter + InterfaceTemplate.objects.get(module_type=module_type, name='Interface 1').delete() + module_type.refresh_from_db() + self.assertEqual(module_type.interface_template_count, 1) + + class RackTypeTestCase(TestCase): @classmethod diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index a0857fc8e..cc888597d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1810,7 +1810,7 @@ class ModuleTypeConsolePortsView(ModuleTypeComponentsView): viewname = 'dcim:moduletype_consoleports' tab = ViewTab( label=_('Console Ports'), - badge=lambda obj: obj.consoleporttemplates.count(), + badge=lambda obj: obj.console_port_template_count, permission='dcim.view_consoleporttemplate', weight=530, hide_if_empty=True @@ -1825,7 +1825,7 @@ class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView): viewname = 'dcim:moduletype_consoleserverports' tab = ViewTab( label=_('Console Server Ports'), - badge=lambda obj: obj.consoleserverporttemplates.count(), + badge=lambda obj: obj.console_server_port_template_count, permission='dcim.view_consoleserverporttemplate', weight=540, hide_if_empty=True @@ -1840,7 +1840,7 @@ class ModuleTypePowerPortsView(ModuleTypeComponentsView): viewname = 'dcim:moduletype_powerports' tab = ViewTab( label=_('Power Ports'), - badge=lambda obj: obj.powerporttemplates.count(), + badge=lambda obj: obj.power_port_template_count, permission='dcim.view_powerporttemplate', weight=550, hide_if_empty=True @@ -1855,7 +1855,7 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView): viewname = 'dcim:moduletype_poweroutlets' tab = ViewTab( label=_('Power Outlets'), - badge=lambda obj: obj.poweroutlettemplates.count(), + badge=lambda obj: obj.power_outlet_template_count, permission='dcim.view_poweroutlettemplate', weight=560, hide_if_empty=True @@ -1870,7 +1870,7 @@ class ModuleTypeInterfacesView(ModuleTypeComponentsView): viewname = 'dcim:moduletype_interfaces' tab = ViewTab( label=_('Interfaces'), - badge=lambda obj: obj.interfacetemplates.count(), + badge=lambda obj: obj.interface_template_count, permission='dcim.view_interfacetemplate', weight=500, hide_if_empty=True @@ -1885,7 +1885,7 @@ class ModuleTypeFrontPortsView(ModuleTypeComponentsView): viewname = 'dcim:moduletype_frontports' tab = ViewTab( label=_('Front Ports'), - badge=lambda obj: obj.frontporttemplates.count(), + badge=lambda obj: obj.front_port_template_count, permission='dcim.view_frontporttemplate', weight=510, hide_if_empty=True @@ -1900,7 +1900,7 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView): viewname = 'dcim:moduletype_rearports' tab = ViewTab( label=_('Rear Ports'), - badge=lambda obj: obj.rearporttemplates.count(), + badge=lambda obj: obj.rear_port_template_count, permission='dcim.view_rearporttemplate', weight=520, hide_if_empty=True @@ -1915,7 +1915,7 @@ class ModuleTypeModuleBaysView(ModuleTypeComponentsView): viewname = 'dcim:moduletype_modulebays' tab = ViewTab( label=_('Module Bays'), - badge=lambda obj: obj.modulebaytemplates.count(), + badge=lambda obj: obj.module_bay_template_count, permission='dcim.view_modulebaytemplate', weight=570, hide_if_empty=True