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.
### Module
The module to which this bay belongs (optional).
### Name
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):
device = DeviceSerializer(nested=True)
module = ModuleSerializer(
nested=True,
fields=('id', 'url', 'display', 'module_bay'),
required=False,
allow_null=True,
default=None
)
installed_module = ModuleSerializer(
nested=True,
fields=('id', 'url', 'display', 'serial', 'description'),
@ -307,7 +314,7 @@ class ModuleBaySerializer(NetBoxModelSerializer):
class Meta:
model = ModuleBay
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',
]
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')

View File

@ -253,13 +253,22 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
class ModuleBayTemplateSerializer(ValidatedModelSerializer):
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:
model = ModuleBayTemplate
fields = [
'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description',
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'description',
'created', 'last_updated',
]
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')
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
class Meta:
model = ModuleBayTemplate
@ -1322,11 +1322,11 @@ class ModuleFilterSet(NetBoxModelFilterSet):
to_field_name='model',
label=_('Module type (model)'),
)
module_bay_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay',
module_bay_id = TreeNodeMultipleChoiceFilter(
queryset=ModuleBay.objects.all(),
to_field_name='id',
label=_('Module Bay (ID)')
field_name='module_bay',
lookup_expr='in',
label=_('Module bay (ID)'),
)
device_id = django_filters.ModelMultipleChoiceFilter(
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(
field_name='installed_module',
queryset=ModuleBay.objects.all(),

View File

@ -70,6 +70,18 @@ class InterfaceCommonForm(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):
super().clean()
@ -88,6 +100,8 @@ class ModuleCommonForm(forms.Form):
self.instance._disable_replication = True
return
module_bays = self._get_module_bay_tree(module_bay)
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
@ -104,13 +118,24 @@ class ModuleCommonForm(forms.Form):
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
resolved_name = template.name
# 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:
if not module_bay.position:
raise forms.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
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)
existing_item = installed_components.get(resolved_name)
# 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 = (
FieldSet('device_type', 'name', 'label', 'position', 'description'),
FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'),
)
class Meta:
model = ModuleBayTemplate
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 = (
FieldSet('device', 'name', 'label', 'position', 'description', 'tags',),
FieldSet('device', 'module', 'name', 'label', 'position', 'description', 'tags',),
)
class Meta:
model = ModuleBay
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(
models.ModuleBay,
fields='__all__',
# fields='__all__',
exclude=('parent',),
filters=ModuleBayFilter
)
class ModuleBayType(ComponentType):
class ModuleBayType(ModularComponentType):
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(
@ -509,7 +515,7 @@ class ModuleBayType(ComponentType):
fields='__all__',
filters=ModuleBayTemplateFilter
)
class ModuleBayTemplateType(ComponentTemplateType):
class ModuleBayTemplateType(ModularComponentTemplateType):
_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.")
)
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):
if MODULE_TOKEN not in self.name:
return self.name
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
def resolve_label(self, module):
if MODULE_TOKEN not in self.label:
return self.label
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
@ -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.
"""
@ -641,16 +668,16 @@ class ModuleBayTemplate(ComponentTemplateModel):
component_model = ModuleBay
class Meta(ComponentTemplateModel.Meta):
class Meta(ModularComponentTemplateModel.Meta):
verbose_name = _('module bay template')
verbose_name_plural = _('module bay templates')
def instantiate(self, device):
def instantiate(self, **kwargs):
return self.component_model(
device=device,
name=self.name,
label=self.label,
position=self.position
position=self.position,
**kwargs
)
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.validators import MaxValueValidator, MinValueValidator
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.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
@ -1087,10 +1087,19 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
# Bays
#
class ModuleBay(ComponentModel, TrackingModelMixin):
class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
"""
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(
verbose_name=_('position'),
max_length=30,
@ -1098,15 +1107,45 @@ class ModuleBay(ComponentModel, TrackingModelMixin):
help_text=_('Identifier to reference when renaming installed components')
)
objects = TreeManager()
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_plural = _('module bays')
class MPTTMeta:
order_insertion_by = ('module',)
def get_absolute_url(self):
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):
"""

View File

@ -1046,7 +1046,8 @@ class Device(
self._instantiate_components(self.device_type.interfacetemplates.all())
self._instantiate_components(self.device_type.rearporttemplates.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())
# Disable bulk_create to accommodate MPTT
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):
is_new = self.pk is None
@ -1228,7 +1240,8 @@ class Module(PrimaryModel, ConfigContextModel):
("powerporttemplates", "powerports", PowerPort),
("poweroutlettemplates", "poweroutlets", PowerOutlet),
("rearporttemplates", "rearports", RearPort),
("frontporttemplates", "frontports", FrontPort)
("frontporttemplates", "frontports", FrontPort),
("modulebaytemplates", "modulebays", ModuleBay),
]:
create_instances = []
update_instances = []
@ -1257,6 +1270,7 @@ class Module(PrimaryModel, ConfigContextModel):
if not disable_replication:
create_instances.append(template_instance)
if component_model is not ModuleBay:
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object
for component in create_instances:
@ -1268,6 +1282,10 @@ class Module(PrimaryModel, ConfigContextModel):
using='default',
update_fields=None
)
else:
# ModuleBays must be saved individually for MPTT
for instance in create_instances:
instance.save()
update_fields = ['module']
component_model.objects.bulk_update(update_instances, update_fields)

View File

@ -313,6 +313,9 @@ class ModularDeviceComponentTable(DeviceComponentTable):
verbose_name=_('Inventory Items'),
)
class Meta(NetBoxTable.Meta):
pass
class CableTerminationTable(NetBoxTable):
cable = tables.Column(
@ -844,7 +847,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
class ModuleBayTable(DeviceComponentTable):
class ModuleBayTable(ModularDeviceComponentTable):
device = tables.Column(
verbose_name=_('Device'),
linkify={
@ -852,6 +855,10 @@ class ModuleBayTable(DeviceComponentTable):
'args': [Accessor('device_id')],
}
)
parent = tables.Column(
linkify=True,
verbose_name=_('Parent'),
)
installed_module = tables.Column(
linkify=True,
verbose_name=_('Installed Module')
@ -873,25 +880,40 @@ class ModuleBayTable(DeviceComponentTable):
verbose_name=_('Module Status')
)
class Meta(DeviceComponentTable.Meta):
class Meta(ModularDeviceComponentTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
'module_asset_tag', 'description', 'tags',
'pk', 'id', 'name', 'device', 'parent', 'label', 'position', 'installed_module', 'module_status',
'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):
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(
extra_buttons=MODULEBAY_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
class Meta(ModuleBayTable.Meta):
model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'module_asset_tag',
'description', 'tags', 'actions',
'pk', 'id', 'parent', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
'module_asset_tag', 'description', 'tags', 'actions',
)
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 6'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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 = 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 2'),
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 = [
{

View File

@ -1871,16 +1871,27 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
)
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(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[2], name='Module Bay 3', description='foobar3'),
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', module_type=module_types[1]),
))
def test_name(self):
params = {'name': ['Module Bay 1', 'Module Bay 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):
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[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]),
))
ModuleBay.objects.bulk_create((
ModuleBay(device=devices[0], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
))
ModuleBay.objects.create(device=devices[0], name='Module Bay 1')
ModuleBay.objects.create(device=devices[1], name='Module Bay 2')
DeviceBay.objects.bulk_create((
DeviceBay(device=devices[0], name='Device Bay 1'),
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 3'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
Module(
@ -2827,7 +2837,8 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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[3], name='Module Bay 4'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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[2], name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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[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 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):
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]}
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):
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()
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):
@classmethod

View File

@ -1899,12 +1899,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_device_modulebays(self):
device = Device.objects.first()
device_bays = (
ModuleBay(device=device, name='Module Bay 1'),
ModuleBay(device=device, name='Module Bay 2'),
ModuleBay(device=device, name='Module Bay 3'),
)
ModuleBay.objects.bulk_create(device_bays)
ModuleBay.objects.create(device=device, name='Module Bay 1')
ModuleBay.objects.create(device=device, name='Module Bay 2')
ModuleBay.objects.create(device=device, name='Module Bay 3')
url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk})
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 5'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
modules = (
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 3'),
)
ModuleBay.objects.bulk_create(module_bays)
for module_bay in module_bays:
module_bay.save()
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):
additional_permissions = [
'dcim.add_moduletype',

View File

@ -39,6 +39,9 @@
{% 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>
{% 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>
</div>
{% endif %}

View File

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

View File

@ -28,9 +28,7 @@
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>
{{ object.get_airflow_display|placeholder }}
</td>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>

View File

@ -39,6 +39,9 @@
{% 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>
{% 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>
</div>
{% endif %}