Closes #10500: Introduce support for nested modules (#16983)

* 10500 add ModularComponentModel

* 10500 add ModularComponentModel

* 10500 add to forms

* 10500 add to serializer, tables

* 10500 template

* 10500 add docs

* 10500 check recursion

* 10500 fix graphql

* 10500 fix conflicting migration from merge

* 10500 token resolution

* 10500 don't return reverse

* 10500 don't return reverse / optimize

* Add ModuleTypeModuleBaysView

* Fix replication of module bays on new modules

* Clean up tables & templates

* Adjust uniqueness constraints

* Correct URL

* Clean up docs

* Fix up serializers

* 10500 add filterset tests

* 10500 add nested validation to Module

* Misc cleanup

* 10500 ModuleBay recursion Test

* 10500 ModuleBay recursion Test

* 10500 ModuleBay recursion Test

* 10500 ModuleBay recursion Test

* Enable MPTT for module bays

* Fix tests

* Fix validation of module token in component names

* Misc cleanup

* Merge migrations

* Fix table ordering

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Arthur Hanson 2024-08-06 00:13:59 +07:00 committed by GitHub
parent 57fe2071a4
commit 796b9e84af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 475 additions and 86 deletions

View File

@ -14,6 +14,10 @@ Module bays represent a space or slot within a device in which a field-replaceab
The device to which this module bay belongs. The device to which this module bay belongs.
### Module
The module to which this bay belongs (optional).
### Name ### Name
The module bay's name. Must be unique to the parent device. The module bay's name. Must be unique to the parent device.

View File

@ -297,6 +297,13 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
class ModuleBaySerializer(NetBoxModelSerializer): class ModuleBaySerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
fields=('id', 'url', 'display', 'module_bay'),
required=False,
allow_null=True,
default=None
)
installed_module = ModuleSerializer( installed_module = ModuleSerializer(
nested=True, nested=True,
fields=('id', 'url', 'display', 'serial', 'description'), fields=('id', 'url', 'display', 'serial', 'description'),
@ -307,7 +314,7 @@ class ModuleBaySerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = ModuleBay model = ModuleBay
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description') brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')

View File

@ -253,13 +253,22 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
class ModuleBayTemplateSerializer(ValidatedModelSerializer): class ModuleBayTemplateSerializer(ValidatedModelSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True nested=True,
required=False,
allow_null=True,
default=None
)
module_type = ModuleTypeSerializer(
nested=True,
required=False,
allow_null=True,
default=None
) )
class Meta: class Meta:
model = ModuleBayTemplate model = ModuleBayTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'description',
'created', 'last_updated', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -858,7 +858,7 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
class Meta: class Meta:
model = ModuleBayTemplate model = ModuleBayTemplate
@ -1322,11 +1322,11 @@ class ModuleFilterSet(NetBoxModelFilterSet):
to_field_name='model', to_field_name='model',
label=_('Module type (model)'), label=_('Module type (model)'),
) )
module_bay_id = django_filters.ModelMultipleChoiceFilter( module_bay_id = TreeNodeMultipleChoiceFilter(
field_name='module_bay',
queryset=ModuleBay.objects.all(), queryset=ModuleBay.objects.all(),
to_field_name='id', field_name='module_bay',
label=_('Module Bay (ID)') lookup_expr='in',
label=_('Module bay (ID)'),
) )
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.all(),
@ -1793,7 +1793,11 @@ class RearPortFilterSet(
) )
class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): class ModuleBayFilterSet(ModularDeviceComponentFilterSet, NetBoxModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=ModuleBay.objects.all(),
label=_('Parent module bay (ID)'),
)
installed_module_id = django_filters.ModelMultipleChoiceFilter( installed_module_id = django_filters.ModelMultipleChoiceFilter(
field_name='installed_module', field_name='installed_module',
queryset=ModuleBay.objects.all(), queryset=ModuleBay.objects.all(),

View File

@ -70,6 +70,18 @@ class InterfaceCommonForm(forms.Form):
class ModuleCommonForm(forms.Form): class ModuleCommonForm(forms.Form):
def _get_module_bay_tree(self, module_bay):
module_bays = []
while module_bay:
module_bays.append(module_bay)
if module_bay.module:
module_bay = module_bay.module.module_bay
else:
module_bay = None
module_bays.reverse()
return module_bays
def clean(self): def clean(self):
super().clean() super().clean()
@ -88,6 +100,8 @@ class ModuleCommonForm(forms.Form):
self.instance._disable_replication = True self.instance._disable_replication = True
return return
module_bays = self._get_module_bay_tree(module_bay)
for templates, component_attribute in [ for templates, component_attribute in [
("consoleporttemplates", "consoleports"), ("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"), ("consoleserverporttemplates", "consoleserverports"),
@ -104,13 +118,24 @@ class ModuleCommonForm(forms.Form):
# Get the templates for the module type. # Get the templates for the module type.
for template in getattr(module_type, templates).all(): for template in getattr(module_type, templates).all():
resolved_name = template.name
# Installing modules with placeholders require that the bay has a position value # Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name and not module_bay.position: if MODULE_TOKEN in template.name:
raise forms.ValidationError( if not module_bay.position:
_("Cannot install module with placeholder values in a module bay with no position defined.") raise forms.ValidationError(
) _("Cannot install module with placeholder values in a module bay with no position defined.")
)
if len(module_bays) != template.name.count(MODULE_TOKEN):
raise forms.ValidationError(
_("Cannot install module with placeholder values in a module bay tree {level} in tree but {tokens} placeholders given.").format(
level=len(module_bays), tokens=template.name.count(MODULE_TOKEN)
)
)
for module_bay in module_bays:
resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
existing_item = installed_components.get(resolved_name) existing_item = installed_components.get(resolved_name)
# It is not possible to adopt components already belonging to a module # It is not possible to adopt components already belonging to a module

View File

@ -1033,15 +1033,15 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
] ]
class ModuleBayTemplateForm(ComponentTemplateForm): class ModuleBayTemplateForm(ModularComponentTemplateForm):
fieldsets = ( fieldsets = (
FieldSet('device_type', 'name', 'label', 'position', 'description'), FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'),
) )
class Meta: class Meta:
model = ModuleBayTemplate model = ModuleBayTemplate
fields = [ fields = [
'device_type', 'name', 'label', 'position', 'description', 'device_type', 'module_type', 'name', 'label', 'position', 'description',
] ]
@ -1453,15 +1453,15 @@ class RearPortForm(ModularDeviceComponentForm):
] ]
class ModuleBayForm(DeviceComponentForm): class ModuleBayForm(ModularDeviceComponentForm):
fieldsets = ( fieldsets = (
FieldSet('device', 'name', 'label', 'position', 'description', 'tags',), FieldSet('device', 'module', 'name', 'label', 'position', 'description', 'tags',),
) )
class Meta: class Meta:
model = ModuleBay model = ModuleBay
fields = [ fields = [
'device', 'name', 'label', 'position', 'description', 'tags', 'device', 'module', 'name', 'label', 'position', 'description', 'tags',
] ]

View File

@ -496,12 +496,18 @@ class ModuleType(NetBoxObjectType):
@strawberry_django.type( @strawberry_django.type(
models.ModuleBay, models.ModuleBay,
fields='__all__', # fields='__all__',
exclude=('parent',),
filters=ModuleBayFilter filters=ModuleBayFilter
) )
class ModuleBayType(ComponentType): class ModuleBayType(ModularComponentType):
installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
children: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.field
def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None:
return self.parent
@strawberry_django.type( @strawberry_django.type(
@ -509,7 +515,7 @@ class ModuleBayType(ComponentType):
fields='__all__', fields='__all__',
filters=ModuleBayTemplateFilter filters=ModuleBayTemplateFilter
) )
class ModuleBayTemplateType(ComponentTemplateType): class ModuleBayTemplateType(ModularComponentTemplateType):
_name: str _name: str

View File

@ -0,0 +1,74 @@
import django.db.models.deletion
import mptt.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0189_moduletype_airflow_rack_airflow_racktype_airflow'),
('extras', '0120_customfield_related_object_filter'),
]
operations = [
migrations.AlterModelOptions(
name='modulebaytemplate',
options={'ordering': ('device_type', 'module_type', '_name')},
),
migrations.RemoveConstraint(
model_name='modulebay',
name='dcim_modulebay_unique_device_name',
),
migrations.AddField(
model_name='modulebay',
name='level',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='modulebay',
name='lft',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='modulebay',
name='module',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'),
),
migrations.AddField(
model_name='modulebay',
name='parent',
field=mptt.fields.TreeForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.modulebay'),
),
migrations.AddField(
model_name='modulebay',
name='rght',
field=models.PositiveIntegerField(default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='modulebay',
name='tree_id',
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
preserve_default=False,
),
migrations.AddField(
model_name='modulebaytemplate',
name='module_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'),
),
migrations.AlterField(
model_name='modulebaytemplate',
name='device_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'),
),
migrations.AddConstraint(
model_name='modulebay',
constraint=models.UniqueConstraint(fields=('device', 'module', 'name'), name='dcim_modulebay_unique_device_module_name'),
),
migrations.AddConstraint(
model_name='modulebaytemplate',
constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_modulebaytemplate_unique_module_type_name'),
),
]

View File

@ -158,14 +158,41 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
_("A component template must be associated with either a device type or a module type.") _("A component template must be associated with either a device type or a module type.")
) )
def _get_module_tree(self, module):
modules = []
all_module_bays = module.device.modulebays.all().select_related('module')
while module:
modules.append(module)
if module.module_bay:
module = module.module_bay.module
else:
module = None
modules.reverse()
return modules
def resolve_name(self, module): def resolve_name(self, module):
if MODULE_TOKEN not in self.name:
return self.name
if module: if module:
return self.name.replace(MODULE_TOKEN, module.module_bay.position) modules = self._get_module_tree(module)
name = self.name
for module in modules:
name = name.replace(MODULE_TOKEN, module.module_bay.position, 1)
return name
return self.name return self.name
def resolve_label(self, module): def resolve_label(self, module):
if MODULE_TOKEN not in self.label:
return self.label
if module: if module:
return self.label.replace(MODULE_TOKEN, module.module_bay.position) modules = self._get_module_tree(module)
label = self.label
for module in modules:
label = label.replace(MODULE_TOKEN, module.module_bay.position, 1)
return label
return self.label return self.label
@ -628,7 +655,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
} }
class ModuleBayTemplate(ComponentTemplateModel): class ModuleBayTemplate(ModularComponentTemplateModel):
""" """
A template for a ModuleBay to be created for a new parent Device. A template for a ModuleBay to be created for a new parent Device.
""" """
@ -641,16 +668,16 @@ class ModuleBayTemplate(ComponentTemplateModel):
component_model = ModuleBay component_model = ModuleBay
class Meta(ComponentTemplateModel.Meta): class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('module bay template') verbose_name = _('module bay template')
verbose_name_plural = _('module bay templates') verbose_name_plural = _('module bay templates')
def instantiate(self, device): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
device=device,
name=self.name, name=self.name,
label=self.label, label=self.label,
position=self.position position=self.position,
**kwargs
) )
instantiate.do_not_call_in_templates = True instantiate.do_not_call_in_templates = True

View File

@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Sum from django.db.models import F, Sum
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
@ -1087,10 +1087,19 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
# Bays # Bays
# #
class ModuleBay(ComponentModel, TrackingModelMixin): class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
""" """
parent = TreeForeignKey(
to='self',
on_delete=models.CASCADE,
related_name='children',
blank=True,
null=True,
editable=False,
db_index=True
)
position = models.CharField( position = models.CharField(
verbose_name=_('position'), verbose_name=_('position'),
max_length=30, max_length=30,
@ -1098,15 +1107,45 @@ class ModuleBay(ComponentModel, TrackingModelMixin):
help_text=_('Identifier to reference when renaming installed components') help_text=_('Identifier to reference when renaming installed components')
) )
objects = TreeManager()
clone_fields = ('device',) clone_fields = ('device',)
class Meta(ComponentModel.Meta): class Meta(ModularComponentModel.Meta):
constraints = (
models.UniqueConstraint(
fields=('device', 'module', 'name'),
name='%(app_label)s_%(class)s_unique_device_module_name'
),
)
verbose_name = _('module bay') verbose_name = _('module bay')
verbose_name_plural = _('module bays') verbose_name_plural = _('module bays')
class MPTTMeta:
order_insertion_by = ('module',)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:modulebay', kwargs={'pk': self.pk}) return reverse('dcim:modulebay', kwargs={'pk': self.pk})
def clean(self):
super().clean()
# Check for recursion
if module := self.module:
module_bays = [self.pk]
modules = []
while module:
if module.pk in modules or module.module_bay.pk in module_bays:
raise ValidationError(_("A module bay cannot belong to a module installed within it."))
modules.append(module.pk)
module_bays.append(module.module_bay.pk)
module = module.module_bay.module if module.module_bay else None
def save(self, *args, **kwargs):
if self.module:
self.parent = self.module.module_bay
super().save(*args, **kwargs)
class DeviceBay(ComponentModel, TrackingModelMixin): class DeviceBay(ComponentModel, TrackingModelMixin):
""" """

View File

@ -1046,7 +1046,8 @@ class Device(
self._instantiate_components(self.device_type.interfacetemplates.all()) self._instantiate_components(self.device_type.interfacetemplates.all())
self._instantiate_components(self.device_type.rearporttemplates.all()) self._instantiate_components(self.device_type.rearporttemplates.all())
self._instantiate_components(self.device_type.frontporttemplates.all()) self._instantiate_components(self.device_type.frontporttemplates.all())
self._instantiate_components(self.device_type.modulebaytemplates.all()) # Disable bulk_create to accommodate MPTT
self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False)
self._instantiate_components(self.device_type.devicebaytemplates.all()) self._instantiate_components(self.device_type.devicebaytemplates.all())
# Disable bulk_create to accommodate MPTT # Disable bulk_create to accommodate MPTT
self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
@ -1207,6 +1208,17 @@ class Module(PrimaryModel, ConfigContextModel):
) )
) )
# Check for recursion
module = self
module_bays = []
modules = []
while module:
if module.pk in modules or module.module_bay.pk in module_bays:
raise ValidationError(_("A module bay cannot belong to a module installed within it."))
modules.append(module.pk)
module_bays.append(module.module_bay.pk)
module = module.module_bay.module if module.module_bay else None
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self.pk is None is_new = self.pk is None
@ -1228,7 +1240,8 @@ class Module(PrimaryModel, ConfigContextModel):
("powerporttemplates", "powerports", PowerPort), ("powerporttemplates", "powerports", PowerPort),
("poweroutlettemplates", "poweroutlets", PowerOutlet), ("poweroutlettemplates", "poweroutlets", PowerOutlet),
("rearporttemplates", "rearports", RearPort), ("rearporttemplates", "rearports", RearPort),
("frontporttemplates", "frontports", FrontPort) ("frontporttemplates", "frontports", FrontPort),
("modulebaytemplates", "modulebays", ModuleBay),
]: ]:
create_instances = [] create_instances = []
update_instances = [] update_instances = []
@ -1257,17 +1270,22 @@ class Module(PrimaryModel, ConfigContextModel):
if not disable_replication: if not disable_replication:
create_instances.append(template_instance) create_instances.append(template_instance)
component_model.objects.bulk_create(create_instances) if component_model is not ModuleBay:
# Emit the post_save signal for each newly created object component_model.objects.bulk_create(create_instances)
for component in create_instances: # Emit the post_save signal for each newly created object
post_save.send( for component in create_instances:
sender=component_model, post_save.send(
instance=component, sender=component_model,
created=True, instance=component,
raw=False, created=True,
using='default', raw=False,
update_fields=None using='default',
) update_fields=None
)
else:
# ModuleBays must be saved individually for MPTT
for instance in create_instances:
instance.save()
update_fields = ['module'] update_fields = ['module']
component_model.objects.bulk_update(update_instances, update_fields) component_model.objects.bulk_update(update_instances, update_fields)

View File

@ -313,6 +313,9 @@ class ModularDeviceComponentTable(DeviceComponentTable):
verbose_name=_('Inventory Items'), verbose_name=_('Inventory Items'),
) )
class Meta(NetBoxTable.Meta):
pass
class CableTerminationTable(NetBoxTable): class CableTerminationTable(NetBoxTable):
cable = tables.Column( cable = tables.Column(
@ -844,7 +847,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description') default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
class ModuleBayTable(DeviceComponentTable): class ModuleBayTable(ModularDeviceComponentTable):
device = tables.Column( device = tables.Column(
verbose_name=_('Device'), verbose_name=_('Device'),
linkify={ linkify={
@ -852,6 +855,10 @@ class ModuleBayTable(DeviceComponentTable):
'args': [Accessor('device_id')], 'args': [Accessor('device_id')],
} }
) )
parent = tables.Column(
linkify=True,
verbose_name=_('Parent'),
)
installed_module = tables.Column( installed_module = tables.Column(
linkify=True, linkify=True,
verbose_name=_('Installed Module') verbose_name=_('Installed Module')
@ -873,25 +880,40 @@ class ModuleBayTable(DeviceComponentTable):
verbose_name=_('Module Status') verbose_name=_('Module Status')
) )
class Meta(DeviceComponentTable.Meta): class Meta(ModularDeviceComponentTable.Meta):
model = models.ModuleBay model = models.ModuleBay
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'pk', 'id', 'name', 'device', 'parent', 'label', 'position', 'installed_module', 'module_status',
'module_asset_tag', 'description', 'tags', 'module_serial', 'module_asset_tag', 'description', 'tags',
) )
default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'module_status', 'description') default_columns = (
'pk', 'name', 'device', 'parent', 'label', 'installed_module', 'module_status', 'description',
)
def render_parent_bay(self, value):
return value.name if value else ''
def render_installed_module(self, value):
return value.module_type if value else ''
class DeviceModuleBayTable(ModuleBayTable): class DeviceModuleBayTable(ModuleBayTable):
name = tables.TemplateColumn(
verbose_name=_('Name'),
template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'
'{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
extra_buttons=MODULEBAY_BUTTONS extra_buttons=MODULEBAY_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(ModuleBayTable.Meta):
model = models.ModuleBay model = models.ModuleBay
fields = ( fields = (
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'module_asset_tag', 'pk', 'id', 'parent', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
'description', 'tags', 'actions', 'module_asset_tag', 'description', 'tags', 'actions',
) )
default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description') default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')

View File

@ -1352,7 +1352,8 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
ModuleBay(device=device, name='Module Bay 5'), ModuleBay(device=device, name='Module Bay 5'),
ModuleBay(device=device, name='Module Bay 6'), ModuleBay(device=device, name='Module Bay 6'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=device, module_bay=module_bays[0], module_type=module_types[0]), Module(device=device, module_bay=module_bays[0], module_type=module_types[0]),
@ -1810,12 +1811,13 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site) device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site)
device_bays = ( module_bays = (
ModuleBay(device=device, name='Device Bay 1'), ModuleBay(device=device, name='Device Bay 1'),
ModuleBay(device=device, name='Device Bay 2'), ModuleBay(device=device, name='Device Bay 2'),
ModuleBay(device=device, name='Device Bay 3'), ModuleBay(device=device, name='Device Bay 3'),
) )
ModuleBay.objects.bulk_create(device_bays) for module_bay in module_bays:
module_bay.save()
cls.create_data = [ cls.create_data = [
{ {

View File

@ -1871,16 +1871,27 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
) )
DeviceType.objects.bulk_create(device_types) DeviceType.objects.bulk_create(device_types)
module_types = (
ModuleType(manufacturer=manufacturer, model='Module Type 1'),
ModuleType(manufacturer=manufacturer, model='Module Type 2'),
)
ModuleType.objects.bulk_create(module_types)
ModuleBayTemplate.objects.bulk_create(( ModuleBayTemplate.objects.bulk_create((
ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1', description='foobar1'), ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1', description='foobar1'),
ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2', description='foobar2'), ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2', description='foobar2', module_type=module_types[0]),
ModuleBayTemplate(device_type=device_types[2], name='Module Bay 3', description='foobar3'), ModuleBayTemplate(device_type=device_types[2], name='Module Bay 3', description='foobar3', module_type=module_types[1]),
)) ))
def test_name(self): def test_name(self):
params = {'name': ['Module Bay 1', 'Module Bay 2']} params = {'name': ['Module Bay 1', 'Module Bay 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module_type(self):
module_types = ModuleType.objects.all()[:2]
params = {'module_type_id': [module_types[0].pk, module_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
queryset = DeviceBayTemplate.objects.all() queryset = DeviceBayTemplate.objects.all()
@ -2309,10 +2320,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]),
FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]),
)) ))
ModuleBay.objects.bulk_create(( ModuleBay.objects.create(device=devices[0], name='Module Bay 1')
ModuleBay(device=devices[0], name='Module Bay 1'), ModuleBay.objects.create(device=devices[1], name='Module Bay 2')
ModuleBay(device=devices[1], name='Module Bay 2'),
))
DeviceBay.objects.bulk_create(( DeviceBay.objects.bulk_create((
DeviceBay(device=devices[0], name='Device Bay 1'), DeviceBay(device=devices[0], name='Device Bay 1'),
DeviceBay(device=devices[1], name='Device Bay 2'), DeviceBay(device=devices[1], name='Device Bay 2'),
@ -2624,7 +2633,8 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
ModuleBay(device=devices[2], name='Module Bay 2'), ModuleBay(device=devices[2], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[2], name='Module Bay 3'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module( Module(
@ -2827,7 +2837,8 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[2], name='Module Bay 3'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
@ -3007,7 +3018,8 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[2], name='Module Bay 3'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
@ -3187,7 +3199,8 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[2], name='Module Bay 3'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
@ -3375,7 +3388,8 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[2], name='Module Bay 3'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
@ -3606,7 +3620,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[2], name='Module Bay 3'),
ModuleBay(device=devices[3], name='Module Bay 4'), ModuleBay(device=devices[3], name='Module Bay 4'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
@ -4053,7 +4068,8 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[2], name='Module Bay 3'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
@ -4242,7 +4258,8 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[2], name='Module Bay 3'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
@ -4421,8 +4438,22 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
ModuleBay(device=devices[0], name='Module Bay 1', label='A', description='First'), ModuleBay(device=devices[0], name='Module Bay 1', label='A', description='First'),
ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'), ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'),
ModuleBay(device=devices[2], name='Module Bay 3', label='C', description='Third'), ModuleBay(device=devices[2], name='Module Bay 3', label='C', description='Third'),
ModuleBay(device=devices[2], name='Module Bay 4', label='D', description='Fourth'),
ModuleBay(device=devices[2], name='Module Bay 5', label='E', description='Fifth'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
)
Module.objects.bulk_create(modules)
module_bays[3].module = modules[0]
module_bays[3].save()
module_bays[4].module = modules[1]
module_bays[4].save()
def test_name(self): def test_name(self):
params = {'name': ['Module Bay 1', 'Module Bay 2']} params = {'name': ['Module Bay 1', 'Module Bay 2']}
@ -4478,6 +4509,11 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
params = {'device': [devices[0].name, devices[1].name]} params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_module(self):
modules = Module.objects.all()[:2]
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()

View File

@ -620,6 +620,100 @@ class DeviceTestCase(TestCase):
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[1]).full_clean() Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[1]).full_clean()
class ModuleBayTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
# Create a CustomField with a default value & assign it to all component models
location = Location.objects.create(name='Location 1', slug='location-1', site=site)
rack = Rack.objects.create(name='Rack 1', site=site)
device = Device.objects.create(name='Device 1', device_type=device_type, role=device_role, site=site, location=location, rack=rack)
module_bays = (
ModuleBay(device=device, name='Module Bay 1', label='A', description='First'),
ModuleBay(device=device, name='Module Bay 2', label='B', description='Second'),
ModuleBay(device=device, name='Module Bay 3', label='C', description='Third'),
)
for module_bay in module_bays:
module_bay.save()
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
modules = (
Module(device=device, module_bay=module_bays[0], module_type=module_type),
Module(device=device, module_bay=module_bays[1], module_type=module_type),
Module(device=device, module_bay=module_bays[2], module_type=module_type),
)
# M3 -> MB3 -> M2 -> MB2 -> M1 -> MB1
Module.objects.bulk_create(modules)
module_bays[1].module = modules[0]
module_bays[1].clean()
module_bays[1].save()
module_bays[2].module = modules[1]
module_bays[2].clean()
module_bays[2].save()
def test_module_bay_recursion(self):
module_bay_1 = ModuleBay.objects.get(name='Module Bay 1')
module_bay_2 = ModuleBay.objects.get(name='Module Bay 2')
module_bay_3 = ModuleBay.objects.get(name='Module Bay 3')
module_1 = Module.objects.get(module_bay=module_bay_1)
module_2 = Module.objects.get(module_bay=module_bay_2)
module_3 = Module.objects.get(module_bay=module_bay_3)
# Confirm error if ModuleBay recurses
with self.assertRaises(ValidationError):
module_bay_1.module = module_3
module_bay_1.clean()
module_bay_1.save()
# Confirm error if Module recurses
with self.assertRaises(ValidationError):
module_1.module_bay = module_bay_3
module_1.clean()
module_1.save()
def test_single_module_token(self):
module_bays = ModuleBay.objects.all()
modules = Module.objects.all()
device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first()
site = Site.objects.first()
location = Location.objects.first()
rack = Rack.objects.first()
# Create DeviceType components
ConsolePortTemplate.objects.create(
device_type=device_type,
name='{module}',
label='{module}',
)
ModuleBayTemplate.objects.create(
device_type=device_type,
name='Module Bay 1'
)
device = Device.objects.create(
name='Device 2',
device_type=device_type,
role=device_role,
site=site,
location=location,
rack=rack
)
cp = device.consoleports.first()
def test_nested_module_token(self):
pass
class CableTestCase(TestCase): class CableTestCase(TestCase):
@classmethod @classmethod

View File

@ -1899,12 +1899,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_modulebays(self): def test_device_modulebays(self):
device = Device.objects.first() device = Device.objects.first()
device_bays = ( ModuleBay.objects.create(device=device, name='Module Bay 1')
ModuleBay(device=device, name='Module Bay 1'), ModuleBay.objects.create(device=device, name='Module Bay 2')
ModuleBay(device=device, name='Module Bay 2'), ModuleBay.objects.create(device=device, name='Module Bay 3')
ModuleBay(device=device, name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(device_bays)
url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk}) url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200) self.assertHttpStatus(self.client.get(url), 200)
@ -1980,7 +1977,8 @@ class ModuleTestCase(
ModuleBay(device=devices[1], name='Module Bay 4'), ModuleBay(device=devices[1], name='Module Bay 4'),
ModuleBay(device=devices[1], name='Module Bay 5'), ModuleBay(device=devices[1], name='Module Bay 5'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
modules = ( modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0]), Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0]),
@ -2782,7 +2780,8 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
ModuleBay(device=device, name='Module Bay 2'), ModuleBay(device=device, name='Module Bay 2'),
ModuleBay(device=device, name='Module Bay 3'), ModuleBay(device=device, name='Module Bay 3'),
) )
ModuleBay.objects.bulk_create(module_bays) for module_bay in module_bays:
module_bay.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')

View File

@ -1314,6 +1314,21 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView):
) )
@register_model_view(ModuleType, 'modulebays', path='module-bays')
class ModuleTypeModuleBaysView(ModuleTypeComponentsView):
child_model = ModuleBayTemplate
table = tables.ModuleBayTemplateTable
filterset = filtersets.ModuleBayTemplateFilterSet
viewname = 'dcim:moduletype_modulebays'
tab = ViewTab(
label=_('Module Bays'),
badge=lambda obj: obj.modulebaytemplates.count(),
permission='dcim.view_modulebaytemplate',
weight=570,
hide_if_empty=True
)
class ModuleTypeImportView(generic.BulkImportView): class ModuleTypeImportView(generic.BulkImportView):
additional_permissions = [ additional_permissions = [
'dcim.add_moduletype', 'dcim.add_moduletype',

View File

@ -39,6 +39,9 @@
{% if perms.dcim.add_rearport %} {% if perms.dcim.add_rearport %}
<li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.device.pk %}">{% trans "Rear Ports" %}</a></li> <li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.device.pk %}">{% trans "Rear Ports" %}</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_modulebay %}
<li><a class="dropdown-item" href="{% url 'dcim:modulebay_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}">{% trans "Module Bays" %}</a></li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -22,6 +22,10 @@
<a href="{% url 'dcim:device_modulebays' pk=object.device.pk %}">{{ object.device }}</a> <a href="{% url 'dcim:device_modulebays' pk=object.device.pk %}">{{ object.device }}</a>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Module" %}</th>
<td>{{ object.module|linkify|placeholder }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Name" %}</th> <th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
@ -31,8 +35,8 @@
<td>{{ object.label|placeholder }}</td> <td>{{ object.label|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Position" %}</th> <th scope="row">{% trans "Position" %}</th>
<td>{{ object.position|placeholder }}</td> <td>{{ object.position|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "Description" %}</th>

View File

@ -27,10 +27,8 @@
<td>{{ object.description|placeholder }}</td> <td>{{ object.description|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Airflow" %}</th> <th scope="row">{% trans "Airflow" %}</th>
<td> <td>{{ object.get_airflow_display|placeholder }}</td>
{{ object.get_airflow_display|placeholder }}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Weight" %}</th> <th scope="row">{% trans "Weight" %}</th>

View File

@ -39,6 +39,9 @@
{% if perms.dcim.add_rearporttemplate %} {% if perms.dcim.add_rearporttemplate %}
<li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_rearports' pk=object.pk %}">{% trans "Rear Ports" %}</a></li> <li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_rearports' pk=object.pk %}">{% trans "Rear Ports" %}</a></li>
{% endif %} {% endif %}
{% if perms.dcim.add_modulebaytemplate %}
<li><a class="dropdown-item" href="{% url 'dcim:modulebaytemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_modulebays' pk=object.pk %}">{% trans "Module Bays" %}</a></li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}