Parcourir la source

Closes #22109: Add template object counts to ModuleType representation in REST & GraphQL APIs (#22302)

Jeremy Stretch il y a 1 jour
Parent
commit
7022bb7eac

+ 2 - 1
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
 

+ 14 - 1
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')

+ 15 - 0
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(

+ 8 - 1
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'),

+ 8 - 0
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')]
 

+ 99 - 0
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),
+    ]

+ 36 - 2
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',

+ 10 - 0
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)

+ 65 - 0
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

+ 8 - 8
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