mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-14 15:52:18 -06:00
feat(dcim): Add device, module and rack count filters
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Introduces `device_count`, `module_count` and `rack_count` filters to enable queries based on the existence and count of the associated device, module or rack instances. Updates forms, filtersets, and GraphQL schema to support these filters, along with tests for validation. Fixes #19523
This commit is contained in:
committed by
Jeremy Stretch
parent
01cbdbb968
commit
cee2a5e0ed
@@ -5,7 +5,7 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
|
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
|
||||||
from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
|
from netbox.api.fields import AttributesField, ChoiceField
|
||||||
from netbox.api.serializers import PrimaryModelSerializer
|
from netbox.api.serializers import PrimaryModelSerializer
|
||||||
from netbox.choices import *
|
from netbox.choices import *
|
||||||
from .manufacturers import ManufacturerSerializer
|
from .manufacturers import ManufacturerSerializer
|
||||||
@@ -45,9 +45,7 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
|
|||||||
device_bay_template_count = serializers.IntegerField(read_only=True)
|
device_bay_template_count = serializers.IntegerField(read_only=True)
|
||||||
module_bay_template_count = serializers.IntegerField(read_only=True)
|
module_bay_template_count = serializers.IntegerField(read_only=True)
|
||||||
inventory_item_template_count = serializers.IntegerField(read_only=True)
|
inventory_item_template_count = serializers.IntegerField(read_only=True)
|
||||||
|
device_count = serializers.IntegerField(read_only=True)
|
||||||
# Related object counts
|
|
||||||
device_count = RelatedObjectCountField('instances')
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
@@ -100,12 +98,13 @@ class ModuleTypeSerializer(PrimaryModelSerializer):
|
|||||||
required=False,
|
required=False,
|
||||||
allow_null=True
|
allow_null=True
|
||||||
)
|
)
|
||||||
|
module_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
|
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
|
||||||
'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
|
'weight', 'weight_unit', 'description', 'attributes', 'owner', 'comments', 'tags', 'custom_fields',
|
||||||
'created', 'last_updated',
|
'created', 'last_updated', 'module_count',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')
|
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description', 'module_count')
|
||||||
|
|||||||
@@ -62,9 +62,8 @@ class RackBaseSerializer(PrimaryModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class RackTypeSerializer(RackBaseSerializer):
|
class RackTypeSerializer(RackBaseSerializer):
|
||||||
manufacturer = ManufacturerSerializer(
|
manufacturer = ManufacturerSerializer(nested=True)
|
||||||
nested=True
|
rack_count = serializers.IntegerField(read_only=True)
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = RackType
|
model = RackType
|
||||||
@@ -72,9 +71,9 @@ class RackTypeSerializer(RackBaseSerializer):
|
|||||||
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
|
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
|
||||||
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
|
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
|
||||||
'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments',
|
'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'description', 'owner', 'comments',
|
||||||
'tags', 'custom_fields', 'created', 'last_updated',
|
'tags', 'custom_fields', 'created', 'last_updated', 'rack_count',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description')
|
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'rack_count')
|
||||||
|
|
||||||
|
|
||||||
class RackSerializer(RackBaseSerializer):
|
class RackSerializer(RackBaseSerializer):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DCIMConfig(AppConfig):
|
|||||||
from netbox.models.features import register_models
|
from netbox.models.features import register_models
|
||||||
from utilities.counters import connect_counters
|
from utilities.counters import connect_counters
|
||||||
from . import signals, search # noqa: F401
|
from . import signals, search # noqa: F401
|
||||||
from .models import CableTermination, Device, DeviceType, VirtualChassis
|
from .models import CableTermination, Device, DeviceType, ModuleType, RackType, VirtualChassis
|
||||||
|
|
||||||
# Register models
|
# Register models
|
||||||
register_models(*self.get_models())
|
register_models(*self.get_models())
|
||||||
@@ -31,4 +31,4 @@ class DCIMConfig(AppConfig):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Register counters
|
# Register counters
|
||||||
connect_counters(Device, DeviceType, VirtualChassis)
|
connect_counters(Device, DeviceType, ModuleType, RackType, VirtualChassis)
|
||||||
|
|||||||
@@ -317,6 +317,9 @@ class RackTypeFilterSet(PrimaryModelFilterSet):
|
|||||||
fields = (
|
fields = (
|
||||||
'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
|
'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_height',
|
||||||
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
|
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
'rack_count',
|
||||||
)
|
)
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
@@ -627,6 +630,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
|
|||||||
'device_bay_template_count',
|
'device_bay_template_count',
|
||||||
'module_bay_template_count',
|
'module_bay_template_count',
|
||||||
'inventory_item_template_count',
|
'inventory_item_template_count',
|
||||||
|
'device_count',
|
||||||
)
|
)
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
@@ -747,7 +751,12 @@ class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description')
|
fields = (
|
||||||
|
'id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
'module_count',
|
||||||
|
)
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
|||||||
model = RackType
|
model = RackType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||||
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
|
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
|
||||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||||
)
|
)
|
||||||
@@ -327,6 +327,11 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Manufacturer')
|
label=_('Manufacturer')
|
||||||
)
|
)
|
||||||
|
rack_count = forms.IntegerField(
|
||||||
|
label=_('Rack count'),
|
||||||
|
required=False,
|
||||||
|
min_value=0,
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
@@ -498,7 +503,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow', name=_('Hardware')
|
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
|
||||||
|
'subdevice_role', 'airflow', name=_('Hardware')
|
||||||
),
|
),
|
||||||
FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
|
FieldSet('has_front_image', 'has_rear_image', name=_('Images')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
@@ -522,6 +528,11 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
|
|||||||
label=_('Part number'),
|
label=_('Part number'),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
device_count = forms.IntegerField(
|
||||||
|
label=_('Device count'),
|
||||||
|
required=False,
|
||||||
|
min_value=0,
|
||||||
|
)
|
||||||
subdevice_role = forms.MultipleChoiceField(
|
subdevice_role = forms.MultipleChoiceField(
|
||||||
label=_('Subdevice role'),
|
label=_('Subdevice role'),
|
||||||
choices=add_blank_choice(SubdeviceRoleChoices),
|
choices=add_blank_choice(SubdeviceRoleChoices),
|
||||||
@@ -633,7 +644,10 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
|||||||
model = ModuleType
|
model = ModuleType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||||
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
|
FieldSet(
|
||||||
|
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
|
||||||
|
'airflow', name=_('Hardware')
|
||||||
|
),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
|
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
|
||||||
'pass_through_ports', name=_('Components')
|
'pass_through_ports', name=_('Components')
|
||||||
@@ -655,6 +669,11 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
|||||||
label=_('Part number'),
|
label=_('Part number'),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
module_count = forms.IntegerField(
|
||||||
|
label=_('Module count'),
|
||||||
|
required=False,
|
||||||
|
min_value=0,
|
||||||
|
)
|
||||||
console_ports = forms.NullBooleanField(
|
console_ports = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Has console ports'),
|
label=_('Has console ports'),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.db.models import Q
|
|||||||
import strawberry
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
from strawberry.scalars import ID
|
from strawberry.scalars import ID
|
||||||
from strawberry_django import FilterLookup
|
from strawberry_django import ComparisonFilterLookup, FilterLookup
|
||||||
|
|
||||||
from core.graphql.filter_mixins import ChangeLogFilterMixin
|
from core.graphql.filter_mixins import ChangeLogFilterMixin
|
||||||
from dcim import models
|
from dcim import models
|
||||||
@@ -328,6 +328,9 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
|
|||||||
)
|
)
|
||||||
default_platform_id: ID | None = strawberry_django.filter_field()
|
default_platform_id: ID | None = strawberry_django.filter_field()
|
||||||
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
|
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
instances: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
|
strawberry_django.filter_field()
|
||||||
|
)
|
||||||
u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
u_height: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
)
|
)
|
||||||
@@ -385,6 +388,7 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
|
|||||||
device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
device_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
module_bay_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
inventory_item_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
|
device_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter_type(models.FrontPort, lookups=True)
|
@strawberry_django.filter_type(models.FrontPort, lookups=True)
|
||||||
@@ -685,6 +689,9 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
|
|||||||
profile_id: ID | None = strawberry_django.filter_field()
|
profile_id: ID | None = strawberry_django.filter_field()
|
||||||
model: FilterLookup[str] | None = strawberry_django.filter_field()
|
model: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
|
part_number: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
instances: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||||
|
strawberry_django.filter_field()
|
||||||
|
)
|
||||||
airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
airflow: Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')] | None = (
|
||||||
strawberry_django.filter_field()
|
strawberry_django.filter_field()
|
||||||
)
|
)
|
||||||
@@ -718,6 +725,7 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, Weig
|
|||||||
inventory_item_templates: (
|
inventory_item_templates: (
|
||||||
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||||
) = strawberry_django.filter_field()
|
) = strawberry_django.filter_field()
|
||||||
|
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter_type(models.Platform, lookups=True)
|
@strawberry_django.filter_type(models.Platform, lookups=True)
|
||||||
@@ -846,6 +854,8 @@ class RackTypeFilter(RackBaseFilterMixin):
|
|||||||
manufacturer_id: ID | None = strawberry_django.filter_field()
|
manufacturer_id: ID | None = strawberry_django.filter_field()
|
||||||
model: FilterLookup[str] | None = strawberry_django.filter_field()
|
model: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
slug: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||||
|
racks: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||||
|
rack_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.filter_type(models.Rack, lookups=True)
|
@strawberry_django.filter_type(models.Rack, lookups=True)
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ class DeviceTypeType(PrimaryObjectType):
|
|||||||
device_bay_template_count: BigInt
|
device_bay_template_count: BigInt
|
||||||
module_bay_template_count: BigInt
|
module_bay_template_count: BigInt
|
||||||
inventory_item_template_count: BigInt
|
inventory_item_template_count: BigInt
|
||||||
|
device_count: BigInt
|
||||||
front_image: strawberry_django.fields.types.DjangoImageType | None
|
front_image: strawberry_django.fields.types.DjangoImageType | None
|
||||||
rear_image: strawberry_django.fields.types.DjangoImageType | None
|
rear_image: strawberry_django.fields.types.DjangoImageType | None
|
||||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||||
@@ -605,6 +606,7 @@ class ModuleTypeProfileType(PrimaryObjectType):
|
|||||||
pagination=True
|
pagination=True
|
||||||
)
|
)
|
||||||
class ModuleTypeType(PrimaryObjectType):
|
class ModuleTypeType(PrimaryObjectType):
|
||||||
|
module_count: BigInt
|
||||||
profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
|
profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
|
||||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||||
|
|
||||||
@@ -709,6 +711,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
|
|||||||
pagination=True
|
pagination=True
|
||||||
)
|
)
|
||||||
class RackTypeType(PrimaryObjectType):
|
class RackTypeType(PrimaryObjectType):
|
||||||
|
rack_count: BigInt
|
||||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
66
netbox/dcim/migrations/0218_devicetype_device_count.py
Normal file
66
netbox/dcim/migrations/0218_devicetype_device_count.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import utilities.fields
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_count_for_type(
|
||||||
|
apps, schema_editor, app_name: str, model_name: str, target_field: str, related_name: str = 'instances'
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a CounterCache field on the specified model by annotating the count of related instances.
|
||||||
|
"""
|
||||||
|
Model = apps.get_model(app_name, model_name)
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
count_subquery = (
|
||||||
|
Model.objects.using(db_alias)
|
||||||
|
.filter(pk=OuterRef('pk'))
|
||||||
|
.annotate(_instance_count=Count(related_name))
|
||||||
|
.values('_instance_count')
|
||||||
|
)
|
||||||
|
Model.objects.using(db_alias).update(**{target_field: Subquery(count_subquery)})
|
||||||
|
|
||||||
|
|
||||||
|
def populate_device_type_device_count(apps, schema_editor):
|
||||||
|
_populate_count_for_type(apps, schema_editor, 'dcim', 'DeviceType', 'device_count')
|
||||||
|
|
||||||
|
|
||||||
|
def populate_module_type_module_count(apps, schema_editor):
|
||||||
|
_populate_count_for_type(apps, schema_editor, 'dcim', 'ModuleType', 'module_count')
|
||||||
|
|
||||||
|
|
||||||
|
def populate_rack_type_rack_count(apps, schema_editor):
|
||||||
|
_populate_count_for_type(apps, schema_editor, 'dcim', 'RackType', 'rack_count', related_name='racks')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0217_owner'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='devicetype',
|
||||||
|
name='device_count',
|
||||||
|
field=utilities.fields.CounterCacheField(
|
||||||
|
default=0, editable=False, to_field='device_type', to_model='dcim.Device'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(populate_device_type_device_count, migrations.RunPython.noop),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='moduletype',
|
||||||
|
name='module_count',
|
||||||
|
field=utilities.fields.CounterCacheField(
|
||||||
|
default=0, editable=False, to_field='module_type', to_model='dcim.Module'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(populate_module_type_module_count, migrations.RunPython.noop),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='racktype',
|
||||||
|
name='rack_count',
|
||||||
|
field=utilities.fields.CounterCacheField(
|
||||||
|
default=0, editable=False, to_field='rack_type', to_model='dcim.Rack'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(populate_rack_type_rack_count, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -185,6 +185,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
to_model='dcim.InventoryItemTemplate',
|
to_model='dcim.InventoryItemTemplate',
|
||||||
to_field='device_type'
|
to_field='device_type'
|
||||||
)
|
)
|
||||||
|
device_count = CounterCacheField(
|
||||||
|
to_model='dcim.Device',
|
||||||
|
to_field='device_type'
|
||||||
|
)
|
||||||
|
|
||||||
clone_fields = (
|
clone_fields = (
|
||||||
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
|
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from extras.models import ConfigContextModel, CustomField
|
|||||||
from netbox.models import PrimaryModel
|
from netbox.models import PrimaryModel
|
||||||
from netbox.models.features import ImageAttachmentsMixin
|
from netbox.models.features import ImageAttachmentsMixin
|
||||||
from netbox.models.mixins import WeightMixin
|
from netbox.models.mixins import WeightMixin
|
||||||
|
from utilities.fields import CounterCacheField
|
||||||
from utilities.jsonschema import validate_schema
|
from utilities.jsonschema import validate_schema
|
||||||
from utilities.string import title
|
from utilities.string import title
|
||||||
|
from utilities.tracking import TrackingModelMixin
|
||||||
from .device_components import *
|
from .device_components import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -92,6 +94,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
null=True,
|
null=True,
|
||||||
verbose_name=_('attributes')
|
verbose_name=_('attributes')
|
||||||
)
|
)
|
||||||
|
module_count = CounterCacheField(
|
||||||
|
to_model='dcim.Module',
|
||||||
|
to_field='module_type'
|
||||||
|
)
|
||||||
|
|
||||||
clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
|
clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
|
||||||
prerequisite_models = (
|
prerequisite_models = (
|
||||||
@@ -186,7 +192,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
return yaml.dump(dict(data), sort_keys=False)
|
return yaml.dump(dict(data), sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
class Module(PrimaryModel, ConfigContextModel):
|
class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
|
||||||
"""
|
"""
|
||||||
A Module represents a field-installable component within a Device which may itself hold multiple device components
|
A Module represents a field-installable component within a Device which may itself hold multiple device components
|
||||||
(for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
|
(for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ from netbox.models.mixins import WeightMixin
|
|||||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||||
from utilities.conversion import to_grams
|
from utilities.conversion import to_grams
|
||||||
from utilities.data import array_to_string, drange
|
from utilities.data import array_to_string, drange
|
||||||
from utilities.fields import ColorField
|
from utilities.fields import ColorField, CounterCacheField
|
||||||
|
from utilities.tracking import TrackingModelMixin
|
||||||
from .device_components import PowerPort
|
from .device_components import PowerPort
|
||||||
from .devices import Device, Module
|
from .devices import Device
|
||||||
|
from .modules import Module
|
||||||
from .power import PowerFeed
|
from .power import PowerFeed
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -144,6 +146,10 @@ class RackType(RackBase):
|
|||||||
max_length=100,
|
max_length=100,
|
||||||
unique=True
|
unique=True
|
||||||
)
|
)
|
||||||
|
rack_count = CounterCacheField(
|
||||||
|
to_model='dcim.Rack',
|
||||||
|
to_field='rack_type'
|
||||||
|
)
|
||||||
|
|
||||||
clone_fields = (
|
clone_fields = (
|
||||||
'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
|
'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_height', 'outer_depth',
|
||||||
@@ -234,7 +240,7 @@ class RackRole(OrganizationalModel):
|
|||||||
verbose_name_plural = _('rack roles')
|
verbose_name_plural = _('rack roles')
|
||||||
|
|
||||||
|
|
||||||
class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
|
class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
|
||||||
"""
|
"""
|
||||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||||
Each Rack is assigned to a Site and (optionally) a Location.
|
Each Rack is assigned to a Site and (optionally) a Location.
|
||||||
@@ -509,7 +515,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
|
|||||||
|
|
||||||
return [u for u in elevation.values()]
|
return [u for u in elevation.values()]
|
||||||
|
|
||||||
def get_available_units(self, u_height=1, rack_face=None, exclude=None, ignore_excluded_devices=False):
|
def get_available_units(self, u_height=1.0, rack_face=None, exclude=None, ignore_excluded_devices=False):
|
||||||
"""
|
"""
|
||||||
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
|
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
|
||||||
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
|
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
|
||||||
@@ -581,9 +587,10 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
|
|||||||
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
||||||
height of the elevation
|
height of the elevation
|
||||||
:param legend_width: Width of the unit legend, in pixels
|
:param legend_width: Width of the unit legend, in pixels
|
||||||
:param margin_width: Width of the rigth-hand margin, in pixels
|
:param margin_width: Width of the right-hand margin, in pixels
|
||||||
:param include_images: Embed front/rear device images where available
|
:param include_images: Embed front/rear device images where available
|
||||||
:param base_url: Base URL for links and images. If none, URLs will be relative.
|
:param base_url: Base URL for links and images. If none, URLs will be relative.
|
||||||
|
:param highlight_params: Dictionary of parameters to be passed to the RackElevationSVG.render_highlight() method
|
||||||
"""
|
"""
|
||||||
elevation = RackElevationSVG(
|
elevation = RackElevationSVG(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ class DeviceTypeTable(PrimaryModelTable):
|
|||||||
template_code=WEIGHT,
|
template_code=WEIGHT,
|
||||||
order_by=('_abs_weight', 'weight_unit')
|
order_by=('_abs_weight', 'weight_unit')
|
||||||
)
|
)
|
||||||
instance_count = columns.LinkedCountColumn(
|
device_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:device_list',
|
viewname='dcim:device_list',
|
||||||
url_params={'device_type_id': 'pk'},
|
url_params={'device_type_id': 'pk'},
|
||||||
verbose_name=_('Instances')
|
verbose_name=_('Device Count'),
|
||||||
)
|
)
|
||||||
console_port_template_count = tables.Column(
|
console_port_template_count = tables.Column(
|
||||||
verbose_name=_('Console Ports')
|
verbose_name=_('Console Ports')
|
||||||
@@ -150,10 +150,10 @@ class DeviceTypeTable(PrimaryModelTable):
|
|||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
|
'pk', 'id', 'model', 'manufacturer', 'default_platform', 'slug', 'part_number', 'u_height',
|
||||||
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
|
'exclude_from_utilization', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
|
||||||
'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
'description', 'comments', 'device_count', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'device_count',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ class ModuleTypeTable(PrimaryModelTable):
|
|||||||
order_by=('_abs_weight', 'weight_unit')
|
order_by=('_abs_weight', 'weight_unit')
|
||||||
)
|
)
|
||||||
attributes = columns.DictColumn()
|
attributes = columns.DictColumn()
|
||||||
instance_count = columns.LinkedCountColumn(
|
module_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:module_list',
|
viewname='dcim:module_list',
|
||||||
url_params={'module_type_id': 'pk'},
|
url_params={'module_type_id': 'pk'},
|
||||||
verbose_name=_('Instances')
|
verbose_name=_('Module Count'),
|
||||||
)
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:moduletype_list'
|
url_name='dcim:moduletype_list'
|
||||||
@@ -69,10 +69,10 @@ class ModuleTypeTable(PrimaryModelTable):
|
|||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
|
'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
|
||||||
'attributes', 'comments', 'tags', 'created', 'last_updated',
|
'attributes', 'module_count', 'comments', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'model', 'profile', 'manufacturer', 'part_number',
|
'pk', 'model', 'profile', 'manufacturer', 'part_number', 'module_count',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ class RackTypeTable(PrimaryModelTable):
|
|||||||
template_code=WEIGHT,
|
template_code=WEIGHT,
|
||||||
order_by=('_abs_max_weight', 'weight_unit')
|
order_by=('_abs_max_weight', 'weight_unit')
|
||||||
)
|
)
|
||||||
instance_count = columns.LinkedCountColumn(
|
rack_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:rack_list',
|
viewname='dcim:rack_list',
|
||||||
url_params={'rack_type_id': 'pk'},
|
url_params={'rack_type_id': 'pk'},
|
||||||
verbose_name=_('Instances')
|
verbose_name=_('Rack Count'),
|
||||||
)
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:rack_list'
|
url_name='dcim:rack_list'
|
||||||
@@ -90,10 +90,10 @@ class RackTypeTable(PrimaryModelTable):
|
|||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
|
'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
|
||||||
'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description',
|
'outer_height', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description',
|
||||||
'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
'comments', 'rack_count', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count',
|
'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'rack_count',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
|
|||||||
|
|
||||||
class RackTypeTest(APIViewTestCases.APIViewTestCase):
|
class RackTypeTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = RackType
|
model = RackType
|
||||||
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
|
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'rack_count', 'slug', 'url']
|
||||||
bulk_update_data = {
|
bulk_update_data = {
|
||||||
'description': 'new description',
|
'description': 'new description',
|
||||||
}
|
}
|
||||||
@@ -610,7 +610,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
|
|||||||
|
|
||||||
class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
|
class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url']
|
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'module_count', 'profile', 'url']
|
||||||
bulk_update_data = {
|
bulk_update_data = {
|
||||||
'part_number': 'ABC123',
|
'part_number': 'ABC123',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -856,9 +856,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
@register_model_view(RackType, 'list', path='', detail=False)
|
@register_model_view(RackType, 'list', path='', detail=False)
|
||||||
class RackTypeListView(generic.ObjectListView):
|
class RackTypeListView(generic.ObjectListView):
|
||||||
queryset = RackType.objects.annotate(
|
queryset = RackType.objects.all()
|
||||||
instance_count=count_related(Rack, 'rack_type')
|
|
||||||
)
|
|
||||||
filterset = filtersets.RackTypeFilterSet
|
filterset = filtersets.RackTypeFilterSet
|
||||||
filterset_form = forms.RackTypeFilterForm
|
filterset_form = forms.RackTypeFilterForm
|
||||||
table = tables.RackTypeTable
|
table = tables.RackTypeTable
|
||||||
@@ -1298,9 +1296,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
@register_model_view(DeviceType, 'list', path='', detail=False)
|
@register_model_view(DeviceType, 'list', path='', detail=False)
|
||||||
class DeviceTypeListView(generic.ObjectListView):
|
class DeviceTypeListView(generic.ObjectListView):
|
||||||
queryset = DeviceType.objects.annotate(
|
queryset = DeviceType.objects.all()
|
||||||
instance_count=count_related(Device, 'device_type')
|
|
||||||
)
|
|
||||||
filterset = filtersets.DeviceTypeFilterSet
|
filterset = filtersets.DeviceTypeFilterSet
|
||||||
filterset_form = forms.DeviceTypeFilterForm
|
filterset_form = forms.DeviceTypeFilterForm
|
||||||
table = tables.DeviceTypeTable
|
table = tables.DeviceTypeTable
|
||||||
@@ -1531,9 +1527,7 @@ class DeviceTypeImportView(generic.BulkImportView):
|
|||||||
|
|
||||||
@register_model_view(DeviceType, 'bulk_edit', path='edit', detail=False)
|
@register_model_view(DeviceType, 'bulk_edit', path='edit', detail=False)
|
||||||
class DeviceTypeBulkEditView(generic.BulkEditView):
|
class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||||
queryset = DeviceType.objects.annotate(
|
queryset = DeviceType.objects.all()
|
||||||
instance_count=count_related(Device, 'device_type')
|
|
||||||
)
|
|
||||||
filterset = filtersets.DeviceTypeFilterSet
|
filterset = filtersets.DeviceTypeFilterSet
|
||||||
table = tables.DeviceTypeTable
|
table = tables.DeviceTypeTable
|
||||||
form = forms.DeviceTypeBulkEditForm
|
form = forms.DeviceTypeBulkEditForm
|
||||||
@@ -1548,9 +1542,7 @@ class DeviceTypeBulkRenameView(generic.BulkRenameView):
|
|||||||
|
|
||||||
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
|
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
|
||||||
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||||
queryset = DeviceType.objects.annotate(
|
queryset = DeviceType.objects.all()
|
||||||
instance_count=count_related(Device, 'device_type')
|
|
||||||
)
|
|
||||||
filterset = filtersets.DeviceTypeFilterSet
|
filterset = filtersets.DeviceTypeFilterSet
|
||||||
table = tables.DeviceTypeTable
|
table = tables.DeviceTypeTable
|
||||||
|
|
||||||
@@ -1652,9 +1644,7 @@ class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
|
|||||||
|
|
||||||
@register_model_view(ModuleType, 'list', path='', detail=False)
|
@register_model_view(ModuleType, 'list', path='', detail=False)
|
||||||
class ModuleTypeListView(generic.ObjectListView):
|
class ModuleTypeListView(generic.ObjectListView):
|
||||||
queryset = ModuleType.objects.annotate(
|
queryset = ModuleType.objects.all()
|
||||||
instance_count=count_related(Module, 'module_type')
|
|
||||||
)
|
|
||||||
filterset = filtersets.ModuleTypeFilterSet
|
filterset = filtersets.ModuleTypeFilterSet
|
||||||
filterset_form = forms.ModuleTypeFilterForm
|
filterset_form = forms.ModuleTypeFilterForm
|
||||||
table = tables.ModuleTypeTable
|
table = tables.ModuleTypeTable
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ from django.test import override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
|
from utilities.counters import connect_counters
|
||||||
from utilities.testing.base import TestCase
|
from utilities.testing.base import TestCase
|
||||||
from utilities.testing.utils import create_test_device
|
from utilities.testing.utils import create_test_device
|
||||||
|
|
||||||
|
|
||||||
class CountersTest(TestCase):
|
class CountersTest(TestCase):
|
||||||
"""
|
"""
|
||||||
Validate the operation of dict_to_filter_params().
|
Validate the operation of the CounterCacheField (tracking counters).
|
||||||
"""
|
"""
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@@ -24,7 +25,7 @@ class CountersTest(TestCase):
|
|||||||
|
|
||||||
def test_interface_count_creation(self):
|
def test_interface_count_creation(self):
|
||||||
"""
|
"""
|
||||||
When a tracked object (Interface) is added the tracking counter should be updated.
|
When a tracked object (Interface) is added, the tracking counter should be updated.
|
||||||
"""
|
"""
|
||||||
device1, device2 = Device.objects.all()
|
device1, device2 = Device.objects.all()
|
||||||
self.assertEqual(device1.interface_count, 2)
|
self.assertEqual(device1.interface_count, 2)
|
||||||
@@ -51,7 +52,7 @@ class CountersTest(TestCase):
|
|||||||
|
|
||||||
def test_interface_count_deletion(self):
|
def test_interface_count_deletion(self):
|
||||||
"""
|
"""
|
||||||
When a tracked object (Interface) is deleted the tracking counter should be updated.
|
When a tracked object (Interface) is deleted, the tracking counter should be updated.
|
||||||
"""
|
"""
|
||||||
device1, device2 = Device.objects.all()
|
device1, device2 = Device.objects.all()
|
||||||
self.assertEqual(device1.interface_count, 2)
|
self.assertEqual(device1.interface_count, 2)
|
||||||
@@ -66,7 +67,7 @@ class CountersTest(TestCase):
|
|||||||
|
|
||||||
def test_interface_count_move(self):
|
def test_interface_count_move(self):
|
||||||
"""
|
"""
|
||||||
When a tracked object (Interface) is moved the tracking counter should be updated.
|
When a tracked object (Interface) is moved, the tracking counter should be updated.
|
||||||
"""
|
"""
|
||||||
device1, device2 = Device.objects.all()
|
device1, device2 = Device.objects.all()
|
||||||
self.assertEqual(device1.interface_count, 2)
|
self.assertEqual(device1.interface_count, 2)
|
||||||
@@ -102,3 +103,35 @@ class CountersTest(TestCase):
|
|||||||
self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
|
self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
|
||||||
device1.refresh_from_db()
|
device1.refresh_from_db()
|
||||||
self.assertEqual(device1.inventory_item_count, 0)
|
self.assertEqual(device1.inventory_item_count, 0)
|
||||||
|
|
||||||
|
def test_signal_connections_are_idempotent_per_sender(self):
|
||||||
|
"""
|
||||||
|
Calling connect_counters() again must not register duplicate receivers.
|
||||||
|
Creating a device after repeated "connect_counters" should still yield +1.
|
||||||
|
"""
|
||||||
|
connect_counters(DeviceType, VirtualChassis)
|
||||||
|
vc, _ = VirtualChassis.objects.get_or_create(name='Virtual Chassis 1')
|
||||||
|
device1, device2 = Device.objects.all()
|
||||||
|
self.assertEqual(device1.device_type.device_count, 2)
|
||||||
|
self.assertEqual(vc.member_count, 0)
|
||||||
|
|
||||||
|
# Call again (should be a no-op for sender registrations)
|
||||||
|
connect_counters(DeviceType, VirtualChassis)
|
||||||
|
|
||||||
|
# Create one new device
|
||||||
|
device3 = create_test_device('Device 3')
|
||||||
|
device3.virtual_chassis = vc
|
||||||
|
device3.save()
|
||||||
|
|
||||||
|
# Ensure counter incremented correctly
|
||||||
|
device1.refresh_from_db()
|
||||||
|
vc.refresh_from_db()
|
||||||
|
self.assertEqual(device1.device_type.device_count, 3, 'device_count should increment exactly once')
|
||||||
|
self.assertEqual(vc.member_count, 1, 'member_count should increment exactly once')
|
||||||
|
|
||||||
|
# Clean up and ensure counter decremented correctly
|
||||||
|
device3.delete()
|
||||||
|
device1.refresh_from_db()
|
||||||
|
vc.refresh_from_db()
|
||||||
|
self.assertEqual(device1.device_type.device_count, 2, 'device_count should decrement exactly once')
|
||||||
|
self.assertEqual(vc.member_count, 0, 'member_count should decrement exactly once')
|
||||||
|
|||||||
Reference in New Issue
Block a user