Skip to content

Commit

Permalink
Closes #10500: Introduce support for nested modules (#16983)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
arthanson and jeremystretch authored Aug 5, 2024
1 parent 57fe207 commit 796b9e8
Show file tree
Hide file tree
Showing 21 changed files with 475 additions and 86 deletions.
4 changes: 4 additions & 0 deletions docs/models/dcim/modulebay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion netbox/dcim/api/serializers_/device_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ class Meta:

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'),
Expand All @@ -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')
Expand Down
13 changes: 11 additions & 2 deletions netbox/dcim/api/serializers_/devicetype_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,22 @@ class Meta:

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')
Expand Down
16 changes: 10 additions & 6 deletions netbox/dcim/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -858,7 +858,7 @@ class Meta:
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')


class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):

class Meta:
model = ModuleBayTemplate
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1793,7 +1793,11 @@ class Meta:
)


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(),
Expand Down
35 changes: 30 additions & 5 deletions netbox/dcim/forms/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ def clean(self):

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

Expand All @@ -88,6 +100,8 @@ def clean(self):
self.instance._disable_replication = True
return

module_bays = self._get_module_bay_tree(module_bay)

for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
Expand All @@ -104,13 +118,24 @@ def clean(self):

# 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:
raise forms.ValidationError(
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
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.")
)

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)

# It is not possible to adopt components already belonging to a module
Expand Down
12 changes: 6 additions & 6 deletions netbox/dcim/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1033,15 +1033,15 @@ class Meta:
]


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


Expand Down Expand Up @@ -1453,15 +1453,15 @@ class Meta:
]


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


Expand Down
12 changes: 9 additions & 3 deletions netbox/dcim/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,20 +496,26 @@ 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(
models.ModuleBayTemplate,
fields='__all__',
filters=ModuleBayTemplateFilter
)
class ModuleBayTemplateType(ComponentTemplateType):
class ModuleBayTemplateType(ModularComponentTemplateType):
_name: str


Expand Down
74 changes: 74 additions & 0 deletions netbox/dcim/migrations/0190_nested_modules.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
41 changes: 34 additions & 7 deletions netbox/dcim/models/device_component_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,41 @@ def clean(self):
_("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


Expand Down Expand Up @@ -628,7 +655,7 @@ def to_yaml(self):
}


class ModuleBayTemplate(ComponentTemplateModel):
class ModuleBayTemplate(ModularComponentTemplateModel):
"""
A template for a ModuleBay to be created for a new parent Device.
"""
Expand All @@ -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

Expand Down
Loading

0 comments on commit 796b9e8

Please sign in to comment.