diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index a6e359feb..6ed7c63c6 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -22,6 +22,7 @@ __all__ = [ 'NestedManufacturerSerializer', 'NestedModuleBaySerializer', 'NestedModuleBayTemplateSerializer', + 'NestedModuleSerializer', 'NestedModuleTypeSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', @@ -260,6 +261,18 @@ class NestedDeviceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedModuleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer(read_only=True) + # TODO: Solve circular dependency + # module_bay = NestedModuleBaySerializer(read_only=True) + module_type = NestedModuleTypeSerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay', 'module_type'] + + class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) @@ -325,11 +338,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer): class NestedModuleBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') - # module = NestedModuleSerializer(read_only=True) + module = NestedModuleSerializer(read_only=True) class Meta: - model = models.DeviceBay - fields = ['id', 'url', 'display', 'name'] + model = models.ModuleBay + fields = ['id', 'url', 'display', 'module', 'name'] class NestedDeviceBaySerializer(WritableNestedSerializer): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c3d6b5cb4..b58355f32 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -517,6 +517,20 @@ class DeviceSerializer(PrimaryModelSerializer): return data +class ModuleSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer() + module_bay = NestedModuleBaySerializer() + module_type = NestedModuleTypeSerializer() + + class Meta: + model = Module + fields = [ + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + class DeviceWithConfigContextSerializer(DeviceSerializer): config_context = serializers.SerializerMethodField() diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 7a866063f..71a768fd5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -32,10 +32,11 @@ router.register('rear-port-templates', views.RearPortTemplateViewSet) router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) -# Devices +# Device/modules router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) +router.register('modules', views.ModuleViewSet) # Device components router.register('console-ports', views.ConsolePortViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e50f9b1b6..378e697c8 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -377,7 +377,7 @@ class PlatformViewSet(CustomFieldModelViewSet): # -# Devices +# Devices/modules # class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): @@ -526,6 +526,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): return Response(response) +class ModuleViewSet(CustomFieldModelViewSet): + queryset = Module.objects.prefetch_related( + 'device', 'module_bay', 'module_type__manufacturer', 'tags', + ) + serializer_class = serializers.ModuleSerializer + filterset_class = filtersets.ModuleFilterSet + + # # Device components # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b0ff992a7..d91a9b574 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -43,6 +43,7 @@ __all__ = ( 'ManufacturerFilterSet', 'ModuleBayFilterSet', 'ModuleBayTemplateFilterSet', + 'ModuleFilterSet', 'ModuleTypeFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', @@ -924,6 +925,42 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex return queryset.exclude(devicebays__isnull=value) +class ModuleFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + tag = TagFilter() + + class Meta: + model = Module + fields = ['id', 'serial', 'asset_tag'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(serial__icontains=value.strip()) | + Q(asset_tag__icontains=value.strip()) | + Q(comments__icontains=value) + ).distinct() + + class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 360fb81cb..378620180 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -32,6 +32,7 @@ __all__ = ( 'InventoryItemBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', + 'ModuleBulkEditForm', 'ModuleBayBulkEditForm', 'ModuleBayTemplateBulkEditForm', 'ModuleTypeBulkEditForm', @@ -473,6 +474,32 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] +class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Module.objects.all(), + widget=forms.MultipleHiddenInput() + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + + class Meta: + nullable_fields = ['serial'] + + class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cable.objects.all(), diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 6092b3d41..8f5ba25b7 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -26,6 +26,7 @@ __all__ = ( 'InventoryItemCSVForm', 'LocationCSVForm', 'ManufacturerCSVForm', + 'ModuleCSVForm', 'ModuleBayCSVForm', 'PlatformCSVForm', 'PowerFeedCSVForm', @@ -400,6 +401,35 @@ class DeviceCSVForm(BaseDeviceCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) +class ModuleCSVForm(CustomFieldModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + module_bay = CSVModelChoiceField( + queryset=ModuleBay.objects.all(), + to_field_name='name' + ) + module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name='model' + ) + + class Meta: + model = Module + fields = ( + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + # Limit module_bay queryset by assigned device + params = {f"device__{self.fields['device'].to_field_name}": data.get('device')} + self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params) + + class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 5e8b333b9..29c09c7f7 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -29,6 +29,8 @@ __all__ = ( 'InventoryItemFilterForm', 'LocationFilterForm', 'ManufacturerFilterForm', + 'ModuleFilterForm', + 'ModuleFilterForm', 'ModuleBayFilterForm', 'ModuleTypeFilterForm', 'PlatformFilterForm', @@ -645,6 +647,37 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi tag = TagFilterField(model) +class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): + model = Module + field_groups = [ + ['q', 'tag'], + ['manufacturer_id', 'module_type_id'], + ['serial', 'asset_tag'], + ] + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + module_type_id = DynamicModelMultipleChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Type'), + fetch_trigger='open' + ) + serial = forms.CharField( + required=False + ) + asset_tag = forms.CharField( + required=False + ) + tag = TagFilterField(model) + + class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = VirtualChassis field_groups = [ diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index ae3cfeaef..672c54c68 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -39,6 +39,7 @@ __all__ = ( 'InventoryItemForm', 'LocationForm', 'ManufacturerForm', + 'ModuleForm', 'ModuleBayForm', 'ModuleBayTemplateForm', 'ModuleTypeForm', @@ -651,6 +652,46 @@ class DeviceForm(TenancyForm, CustomFieldModelForm): self.fields['position'].widget.choices = [(position, f'U{position}')] +class ModuleForm(CustomFieldModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + initial_params={ + 'modulebays': '$module_bay' + } + ) + module_bay = DynamicModelChoiceField( + queryset=ModuleBay.objects.all(), + query_params={ + 'device_id': '$device' + } + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + initial_params={ + 'device_types': '$device_type' + } + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Module + fields = [ + 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', 'comments', + ] + + class CableForm(TenancyForm, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index d50c64d33..7f660b192 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -56,6 +56,9 @@ class DCIMQuery(graphene.ObjectType): manufacturer = ObjectField(ManufacturerType) manufacturer_list = ObjectListField(ManufacturerType) + module = ObjectField(ModuleType) + module_list = ObjectListField(ModuleType) + module_bay = ObjectField(ModuleBayType) module_bay_list = ObjectListField(ModuleBayType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index c1a8822d8..51e196076 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -27,6 +27,7 @@ __all__ = ( 'InventoryItemType', 'LocationType', 'ManufacturerType', + 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', 'ModuleTypeType', @@ -257,6 +258,14 @@ class ManufacturerType(OrganizationalObjectType): filterset_class = filtersets.ManufacturerFilterSet +class ModuleType(ComponentObjectType): + + class Meta: + model = models.Module + fields = '__all__' + filterset_class = filtersets.ModuleFilterSet + + class ModuleBayType(ComponentObjectType): class Meta: diff --git a/netbox/dcim/migrations/0145_modules.py b/netbox/dcim/migrations/0145_modules.py index b9cb7bcc5..c9a332846 100644 --- a/netbox/dcim/migrations/0145_modules.py +++ b/netbox/dcim/migrations/0145_modules.py @@ -95,36 +95,110 @@ class Migration(migrations.Migration): 'unique_together': {('manufacturer', 'model')}, }, ), + migrations.CreateModel( + name='ModuleBay', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('device', '_name'), + 'unique_together': {('device', 'name')}, + }, + ), + migrations.CreateModel( + name='Module', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('local_context_data', models.JSONField(blank=True, null=True)), + ('serial', models.CharField(blank=True, max_length=50)), + ('asset_tag', models.CharField(blank=True, max_length=50, null=True, unique=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.device')), + ('module_bay', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='installed_module', to='dcim.modulebay')), + ('module_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.moduletype')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('module_bay',), + }, + ), + migrations.AddField( + model_name='consoleport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.module'), + ), migrations.AddField( model_name='consoleporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='consoleserverport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.module'), + ), migrations.AddField( model_name='consoleserverporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='frontport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.module'), + ), migrations.AddField( model_name='frontporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='interface', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.module'), + ), migrations.AddField( model_name='interfacetemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='poweroutlet', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.module'), + ), migrations.AddField( model_name='poweroutlettemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='powerport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.module'), + ), migrations.AddField( model_name='powerporttemplate', name='module_type', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.moduletype'), ), + migrations.AddField( + model_name='rearport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.module'), + ), migrations.AddField( model_name='rearporttemplate', name='module_type', @@ -140,7 +214,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='frontporttemplate', - unique_together={('device_type', 'name'), ('module_type', 'name'), ('rear_port', 'rear_port_position')}, + unique_together={('device_type', 'name'), ('rear_port', 'rear_port_position'), ('module_type', 'name')}, ), migrations.AlterUniqueTogether( name='interfacetemplate', @@ -175,23 +249,4 @@ class Migration(migrations.Migration): 'unique_together': {('device_type', 'name')}, }, ), - migrations.CreateModel( - name='ModuleBay', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=64)), - ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), - ('label', models.CharField(blank=True, max_length=64)), - ('description', models.CharField(blank=True, max_length=200)), - ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ], - options={ - 'ordering': ('device', '_name'), - 'unique_together': {('device', 'name')}, - }, - ), ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index a030dc3a8..8d4b1dce6 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -27,6 +27,7 @@ __all__ = ( 'InventoryItem', 'Location', 'Manufacturer', + 'Module', 'ModuleBay', 'ModuleBayTemplate', 'ModuleType', diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index d522d543a..a22118de0 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -142,12 +142,12 @@ class ConsolePortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return ConsolePort( - device=device, name=self.name, label=self.label, - type=self.type + type=self.type, + **kwargs ) @@ -169,12 +169,12 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return ConsoleServerPort( - device=device, name=self.name, label=self.label, - type=self.type + type=self.type, + **kwargs ) @@ -208,14 +208,14 @@ class PowerPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return PowerPort( - device=device, name=self.name, label=self.label, type=self.type, maximum_draw=self.maximum_draw, - allocated_draw=self.allocated_draw + allocated_draw=self.allocated_draw, + **kwargs ) def clean(self): @@ -273,18 +273,18 @@ class PowerOutletTemplate(ModularComponentTemplateModel): f"Parent power port ({self.power_port}) must belong to the same module type" ) - def instantiate(self, device): + def instantiate(self, **kwargs): if self.power_port: - power_port = PowerPort.objects.get(device=device, name=self.power_port.name) + power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs) else: power_port = None return PowerOutlet( - device=device, name=self.name, label=self.label, type=self.type, power_port=power_port, - feed_leg=self.feed_leg + feed_leg=self.feed_leg, + **kwargs ) @@ -316,13 +316,13 @@ class InterfaceTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return Interface( - device=device, name=self.name, label=self.label, type=self.type, - mgmt_only=self.mgmt_only + mgmt_only=self.mgmt_only, + **kwargs ) @@ -381,19 +381,19 @@ class FrontPortTemplate(ModularComponentTemplateModel): except RearPortTemplate.DoesNotExist: pass - def instantiate(self, device): + def instantiate(self, **kwargs): if self.rear_port: - rear_port = RearPort.objects.get(device=device, name=self.rear_port.name) + rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs) else: rear_port = None return FrontPort( - device=device, name=self.name, label=self.label, type=self.type, color=self.color, rear_port=rear_port, - rear_port_position=self.rear_port_position + rear_port_position=self.rear_port_position, + **kwargs ) @@ -424,14 +424,14 @@ class RearPortTemplate(ModularComponentTemplateModel): ('module_type', 'name'), ) - def instantiate(self, device): + def instantiate(self, **kwargs): return RearPort( - device=device, name=self.name, label=self.label, type=self.type, color=self.color, - positions=self.positions + positions=self.positions, + **kwargs ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 08e069239..fc80b29c9 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -87,6 +87,19 @@ class ComponentModel(PrimaryModel): return self.device +class ModularComponentModel(ComponentModel): + module = models.ForeignKey( + to='dcim.Module', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + class LinkTermination(models.Model): """ An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples @@ -234,7 +247,7 @@ class PathEndpoint(models.Model): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): +class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -262,7 +275,7 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): +class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -294,7 +307,7 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPort(ComponentModel, LinkTermination, PathEndpoint): +class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -387,7 +400,7 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): +class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -502,7 +515,7 @@ class BaseInterface(models.Model): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): +class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -765,7 +778,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class FrontPort(ComponentModel, LinkTermination): +class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. """ @@ -819,7 +832,7 @@ class FrontPort(ComponentModel, LinkTermination): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RearPort(ComponentModel, LinkTermination): +class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ab06b7dc5..8d0a7ae19 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -26,6 +26,7 @@ __all__ = ( 'DeviceRole', 'DeviceType', 'Manufacturer', + 'Module', 'ModuleType', 'Platform', 'VirtualChassis', @@ -906,31 +907,31 @@ class Device(PrimaryModel, ConfigContextModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()] ) ConsoleServerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()] ) PowerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.powerporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()] ) PowerOutlet.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()] + [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()] ) Interface.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.interfacetemplates.all()] + [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()] ) RearPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.rearporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()] ) FrontPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.frontporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()] ) ModuleBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.modulebaytemplates.all()] + [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()] ) DeviceBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()] + [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] ) # Update Site and Rack assignment for any child Devices @@ -1008,6 +1009,85 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Module(PrimaryModel, ConfigContextModel): + """ + A Module represents a field-installable component within a Device which may itself hold multiple device components + (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='modules' + ) + module_bay = models.OneToOneField( + to='dcim.ModuleBay', + on_delete=models.CASCADE, + related_name='installed_module' + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.PROTECT, + related_name='instances' + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) + asset_tag = models.CharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', + help_text='A unique tag used to identify this device' + ) + comments = models.TextField( + blank=True + ) + + clone_fields = ('device', 'module_type') + + class Meta: + ordering = ('module_bay',) + + def __str__(self): + return str(self.module_type) + + def get_absolute_url(self): + return reverse('dcim:module', args=[self.pk]) + + def save(self, *args, **kwargs): + is_new = not bool(self.pk) + + super().save(*args, **kwargs) + + # If this is a new Module, instantiate all its related components per the ModuleType definition + if is_new: + ConsolePort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()] + ) + ConsoleServerPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()] + ) + PowerPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()] + ) + PowerOutlet.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()] + ) + Interface.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()] + ) + RearPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()] + ) + FrontPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()] + ) + + # # Virtual chassis # diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 688b8771c..993ae0518 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -6,7 +6,7 @@ from dcim.models import ConsolePort, Interface, PowerPort from .cables import * from .devices import * from .devicetypes import * -from .moduletypes import * +from .modules import * from .power import * from .racks import * from .sites import * diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index df1d79aa4..f8616b642 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -725,26 +725,31 @@ class ModuleBayTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + installed_module = tables.Column( + linkify=True, + verbose_name='Installed module' + ) tags = TagColumn( url_name='dcim:modulebay_list' ) class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'device', 'label', 'description', 'tags') - default_columns = ('pk', 'name', 'device', 'label', 'description') + fields = ('pk', 'id', 'name', 'device', 'label', 'installed_module', 'description', 'tags') + default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') class DeviceModuleBayTable(ModuleBayTable): actions = ButtonsColumn( - model=ModuleBay, - buttons=('edit', 'delete') + model=DeviceBay, + buttons=('edit', 'delete'), + prepend_template=MODULEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = ModuleBay - fields = ('pk', 'id', 'name', 'label', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'label', 'description', 'actions') + fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions') + default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions') class InventoryItemTable(DeviceComponentTable): diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py new file mode 100644 index 000000000..6d620433a --- /dev/null +++ b/netbox/dcim/tables/modules.py @@ -0,0 +1,61 @@ +import django_tables2 as tables + +from dcim.models import Module, ModuleType +from utilities.tables import BaseTable, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn + +__all__ = ( + 'ModuleTable', + 'ModuleTypeTable', +) + + +class ModuleTypeTable(BaseTable): + pk = ToggleColumn() + model = tables.Column( + linkify=True, + verbose_name='Module Type' + ) + instance_count = LinkedCountColumn( + viewname='dcim:module_list', + url_params={'module_type_id': 'pk'}, + verbose_name='Instances' + ) + comments = MarkdownColumn() + tags = TagColumn( + url_name='dcim:moduletype_list' + ) + + class Meta(BaseTable.Meta): + model = ModuleType + fields = ( + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'model', 'manufacturer', 'part_number', + ) + + +class ModuleTable(BaseTable): + pk = ToggleColumn() + device = tables.Column( + linkify=True + ) + module_bay = tables.Column( + linkify=True + ) + module_type = tables.Column( + linkify=True + ) + comments = MarkdownColumn() + tags = TagColumn( + url_name='dcim:module_list' + ) + + class Meta(BaseTable.Meta): + model = Module + fields = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', + ) diff --git a/netbox/dcim/tables/moduletypes.py b/netbox/dcim/tables/moduletypes.py deleted file mode 100644 index 23bf2e965..000000000 --- a/netbox/dcim/tables/moduletypes.py +++ /dev/null @@ -1,34 +0,0 @@ -import django_tables2 as tables - -from dcim.models import ModuleType -from utilities.tables import BaseTable, MarkdownColumn, TagColumn, ToggleColumn - -__all__ = ( - 'ModuleTypeTable', -) - - -class ModuleTypeTable(BaseTable): - pk = ToggleColumn() - model = tables.Column( - linkify=True, - verbose_name='Device Type' - ) - # instance_count = LinkedCountColumn( - # viewname='dcim:module_list', - # url_params={'module_type_id': 'pk'}, - # verbose_name='Instances' - # ) - comments = MarkdownColumn() - tags = TagColumn( - url_name='dcim:moduletype_list' - ) - - class Meta(BaseTable.Meta): - model = ModuleType - fields = ( - 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', - ) - default_columns = ( - 'pk', 'model', 'manufacturer', 'part_number', - ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index ccca32be8..6b44c4b3f 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -321,3 +321,17 @@ DEVICEBAY_BUTTONS = """ {% endif %} {% endif %} """ + +MODULEBAY_BUTTONS = """ +{% if perms.dcim.add_module %} + {% if record.installed_module %} + + + + {% else %} + + + + {% endif %} +{% endif %} +""" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 597b6d50b..3b6410c8c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,7 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN -from utilities.testing import APITestCase, APIViewTestCases +from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.models import WirelessLAN @@ -1105,6 +1105,67 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) +class ModuleTest(APIViewTestCases.APIViewTestCase): + model = Module + brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url'] + bulk_update_data = { + 'serial': '1234ABCD', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + device = create_test_device('Test Device 1') + + module_types = ( + ModuleType(manufacturer=manufacturer, model='Module Type 1'), + ModuleType(manufacturer=manufacturer, model='Module Type 2'), + ModuleType(manufacturer=manufacturer, model='Module Type 3'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=device, name='Module Bay 1'), + ModuleBay(device=device, name='Module Bay 2'), + ModuleBay(device=device, name='Module Bay 3'), + ModuleBay(device=device, name='Module Bay 4'), + ModuleBay(device=device, name='Module Bay 5'), + ModuleBay(device=device, name='Module Bay 6'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=device, module_bay=module_bays[0], module_type=module_types[0]), + Module(device=device, module_bay=module_bays[1], module_type=module_types[1]), + Module(device=device, module_bay=module_bays[2], module_type=module_types[2]), + ) + Module.objects.bulk_create(modules) + + cls.create_data = [ + { + 'device': device.pk, + 'module_bay': module_bays[3].pk, + 'module_type': module_types[0].pk, + 'serial': 'ABC123', + 'asset_tag': 'Foo1', + }, + { + 'device': device.pk, + 'module_bay': module_bays[4].pk, + 'module_type': module_types[1].pk, + 'serial': 'DEF456', + 'asset_tag': 'Foo2', + }, + { + 'device': device.pk, + 'module_bay': module_bays[5].pk, + 'module_type': module_types[2].pk, + 'serial': 'GHI789', + 'asset_tag': 'Foo3', + }, + ] + + class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 4fd1286da..8f04fb4d9 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -7,7 +7,7 @@ from dcim.models import * from ipam.models import ASN, IPAddress, RIR from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices @@ -1648,6 +1648,79 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = Module.objects.all() + filterset = ModuleFilterSet + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + devices = ( + create_test_device('Test Device 1'), + create_test_device('Test Device 2'), + create_test_device('Test Device 3'), + ) + + module_types = ( + ModuleType(manufacturer=manufacturers[0], model='Module Type 1'), + ModuleType(manufacturer=manufacturers[1], model='Module Type 2'), + ModuleType(manufacturer=manufacturers[2], model='Module Type 3'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[0], name='Module Bay 2'), + ModuleBay(device=devices[0], name='Module Bay 3'), + ModuleBay(device=devices[1], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + ModuleBay(device=devices[1], name='Module Bay 3'), + ModuleBay(device=devices[2], name='Module Bay 1'), + ModuleBay(device=devices[2], name='Module Bay 2'), + ModuleBay(device=devices[2], name='Module Bay 3'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'), + Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'), + Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'), + Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'), + Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'), + Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'), + Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'), + Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'), + Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'), + ) + Module.objects.bulk_create(modules) + + def test_manufacturer(self): + manufacturers = Manufacturer.objects.all()[:2] + params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_device(self): + device_types = Device.objects.all()[:2] + params = {'device_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_serial(self): + params = {'asset_tag': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_asset_tag(self): + params = {'asset_tag': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ConsolePort.objects.all() filterset = ConsolePortFilterSet diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 6094fe739..12216a8ac 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1697,6 +1697,75 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.assertHttpStatus(self.client.get(url), 200) +class ModuleTestCase( + # Module does not support bulk renaming (no name field) or + # bulk creation (need to specify module bays) + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + model = Module + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Generic', slug='generic') + devices = ( + create_test_device('Device 1'), + create_test_device('Device 2'), + ) + + module_types = ( + ModuleType(manufacturer=manufacturer, model='Module Type 1'), + ModuleType(manufacturer=manufacturer, model='Module Type 2'), + ModuleType(manufacturer=manufacturer, model='Module Type 3'), + ModuleType(manufacturer=manufacturer, model='Module Type 4'), + ) + ModuleType.objects.bulk_create(module_types) + + module_bays = ( + ModuleBay(device=devices[0], name='Module Bay 1'), + ModuleBay(device=devices[0], name='Module Bay 2'), + ModuleBay(device=devices[0], name='Module Bay 3'), + ModuleBay(device=devices[1], name='Module Bay 1'), + ModuleBay(device=devices[1], name='Module Bay 2'), + ModuleBay(device=devices[1], name='Module Bay 3'), + ) + ModuleBay.objects.bulk_create(module_bays) + + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0]), + Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1]), + Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2]), + ) + Module.objects.bulk_create(modules) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'device': devices[1].pk, + 'module_bay': module_bays[3].pk, + 'module_type': module_types[0].pk, + 'serial': 'A', + 'tags': [t.pk for t in tags], + } + + cls.bulk_edit_data = { + 'module_type': module_types[3].pk, + } + + cls.csv_data = ( + "device,module_bay,module_type,serial,asset_tag", + "Device 2,Module Bay 1,Module Type 1,A,A", + "Device 2,Module Bay 2,Module Type 2,B,B", + "Device 2,Module Bay 3,Module Type 3,C,C", + ) + + class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): model = ConsolePort diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index e1c1e200f..8ec30c0cc 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -254,12 +254,24 @@ urlpatterns = [ path('devices//device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'), path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), - path('devices//changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - path('devices//journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}), + path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path('devices//journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}), path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + # Modules + path('modules/', views.ModuleListView.as_view(), name='module_list'), + path('modules/add/', views.ModuleEditView.as_view(), name='module_add'), + path('modules/import/', views.ModuleBulkImportView.as_view(), name='module_import'), + path('modules/edit/', views.ModuleBulkEditView.as_view(), name='module_bulk_edit'), + path('modules/delete/', views.ModuleBulkDeleteView.as_view(), name='module_bulk_delete'), + path('modules//', views.ModuleView.as_view(), name='module'), + path('modules//edit/', views.ModuleEditView.as_view(), name='module_edit'), + path('modules//delete/', views.ModuleDeleteView.as_view(), name='module_delete'), + path('modules//changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}), + path('modules//journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}), + # Console ports path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f673e64d5..3bc264554 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -13,7 +13,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from circuits.models import Circuit -from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView +from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic @@ -30,7 +30,7 @@ from .constants import NONCONNECTABLE_IFACE_TYPES from .models import ( Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed, + InventoryItem, Manufacturer, Module, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, SiteGroup, VirtualChassis, ) @@ -1629,14 +1629,6 @@ class DeviceConfigContextView(ObjectConfigContextView): base_template = 'dcim/device/base.html' -class DeviceChangeLogView(ObjectChangeLogView): - base_template = 'dcim/device/base.html' - - -class DeviceJournalView(ObjectJournalView): - base_template = 'dcim/device/base.html' - - class DeviceEditView(generic.ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm @@ -1685,6 +1677,49 @@ class DeviceBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceTable +# +# Devices +# + +class ModuleListView(generic.ObjectListView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + filterset_form = forms.ModuleFilterForm + table = tables.ModuleTable + + +class ModuleView(generic.ObjectView): + queryset = Module.objects.all() + + +class ModuleEditView(generic.ObjectEditView): + queryset = Module.objects.all() + model_form = forms.ModuleForm + + +class ModuleDeleteView(generic.ObjectDeleteView): + queryset = Module.objects.all() + + +class ModuleBulkImportView(generic.BulkImportView): + queryset = Module.objects.all() + model_form = forms.ModuleCSVForm + table = tables.ModuleTable + + +class ModuleBulkEditView(generic.BulkEditView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + table = tables.ModuleTable + form = forms.ModuleBulkEditForm + + +class ModuleBulkDeleteView(generic.BulkDeleteView): + queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + filterset = filtersets.ModuleFilterSet + table = tables.ModuleTable + + # # Console ports # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a2bec4710..52359dcc6 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -139,6 +139,7 @@ DEVICES_MENU = Menu( label='Devices', items=( get_model_item('dcim', 'device', 'Devices'), + get_model_item('dcim', 'module', 'Modules'), get_model_item('dcim', 'devicerole', 'Device Roles'), get_model_item('dcim', 'platform', 'Platforms'), get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'), diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 80ccb69a2..d9ff0657c 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -102,6 +102,22 @@ + {% with devicebay_count=object.devicebays.count %} + {% if devicebay_count %} + + {% endif %} + {% endwith %} + + {% with modulebay_count=object.modulebays.count %} + {% if modulebay_count %} + + {% endif %} + {% endwith %} + {% with interface_count=object.interfaces_count %} {% if interface_count %} - {% endif %} - {% endwith %} - - {% with devicebay_count=object.devicebays.count %} - {% if devicebay_count %} - - {% endif %} - {% endwith %} - {% with inventoryitem_count=object.inventoryitems.count %} {% if inventoryitem_count %} + {% with devicebay_count=object.devicebaytemplates.count %} + {% if devicebay_count %} + + {% endif %} + {% endwith %} + + {% with modulebay_count=object.modulebaytemplates.count %} + {% if modulebay_count %} + + {% endif %} + {% endwith %} + {% with interface_count=object.interfacetemplates.count %} {% if interface_count %} {% endif %} {% endwith %} - - {% with modulebay_count=object.modulebaytemplates.count %} - {% if modulebay_count %} - - {% endif %} - {% endwith %} - - {% with devicebay_count=object.devicebaytemplates.count %} - {% if devicebay_count %} - - {% endif %} - {% endwith %} {% endblock %} diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html new file mode 100644 index 000000000..8410b9556 --- /dev/null +++ b/netbox/templates/dcim/module.html @@ -0,0 +1,154 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load tz %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+
+
Module
+
+ + + + + + + + + + + + + + + + + + + + + +
Device + {{ object.device }} +
Device Type + {{ object.device.device_type }} +
Module Type + {{ object.module_type }} +
Serial Number{{ object.serial|placeholder }}
Asset Tag{{ object.asset_tag|placeholder }}
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+
Components
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Interfaces + {% with component_count=object.interfaces.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
Console Ports + {% with component_count=object.consoleports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
Console Server Ports + {% with component_count=object.consoleserverports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
Power Ports + {% with component_count=object.powerports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
Power Outlets + {% with component_count=object.poweroutlets.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
Front Ports + {% with component_count=object.frontports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
Rear Ports + {% with component_count=object.rearports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + None + {% endif %} + {% endwith %} +
+
+
+ {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %}