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

This commit is contained in:
Jeremy Stretch 2026-05-27 12:38:47 -04:00 committed by GitHub
parent d13f5e8214
commit 7022bb7eac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 265 additions and 13 deletions

View File

@ -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

View File

@ -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')

View File

@ -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(

View File

@ -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'),

View File

@ -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')]

View File

@ -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),
]

View File

@ -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',

View File

@ -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)

View File

@ -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

View File

@ -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