mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
* 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:
parent
57fe2071a4
commit
796b9e84af
@ -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.
|
||||
|
@ -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')
|
||||
|
@ -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')
|
||||
|
@ -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(),
|
||||
|
@ -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:
|
||||
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
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
74
netbox/dcim/migrations/0190_nested_modules.py
Normal file
74
netbox/dcim/migrations/0190_nested_modules.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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,17 +1270,22 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
if not disable_replication:
|
||||
create_instances.append(template_instance)
|
||||
|
||||
component_model.objects.bulk_create(create_instances)
|
||||
# Emit the post_save signal for each newly created object
|
||||
for component in create_instances:
|
||||
post_save.send(
|
||||
sender=component_model,
|
||||
instance=component,
|
||||
created=True,
|
||||
raw=False,
|
||||
using='default',
|
||||
update_fields=None
|
||||
)
|
||||
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:
|
||||
post_save.send(
|
||||
sender=component_model,
|
||||
instance=component,
|
||||
created=True,
|
||||
raw=False,
|
||||
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)
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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 = [
|
||||
{
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
@ -31,8 +35,8 @@
|
||||
<td>{{ object.label|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Position" %}</th>
|
||||
<td>{{ object.position|placeholder }}</td>
|
||||
<th scope="row">{% trans "Position" %}</th>
|
||||
<td>{{ object.position|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
|
@ -27,10 +27,8 @@
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Airflow" %}</th>
|
||||
<td>
|
||||
{{ object.get_airflow_display|placeholder }}
|
||||
</td>
|
||||
<th scope="row">{% trans "Airflow" %}</th>
|
||||
<td>{{ object.get_airflow_display|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Weight" %}</th>
|
||||
|
@ -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 %}
|
||||
|
Loading…
Reference in New Issue
Block a user