Просмотр исходного кода

Closes #19002: Module type profiles (#19014)

* Move Module & ModuleType models to a separate file

* Add ModuleTypeProfile & related fields

* Initial work on JSON schema validation

* Add attributes property on ModuleType

* Introduce MultipleOfValidator

* Introduce JSONSchemaProperty

* Enable dynamic form field rendering

* Misc cleanup

* Fix migration conflict

* Ensure deterministic ordering of attriubte fields

* Support choices & default values

* Include module type attributes on module view

* Enable modifying individual attributes via REST API

* Enable filtering by attribute values

* Add documentation & tests

* Schema should be optional

* Include attributes column for profiles

* Profile is nullable

* Include some initial profiles to be installed via migration

* Fix migrations conflict

* Fix filterset test

* Misc cleanup

* Fixes #19023: get_field_value() should respect null values in bound forms (#19024)

* Skip filters which do not specify a JSON-serializable value

* Fix handling of array item types

* Fix initial data in schema field during bulk edit

* Implement sanity checking for JSON schema definitions

* Fall back to filtering by string value
Jeremy Stretch 10 месяцев назад
Родитель
Сommit
8d7889e2c0
47 измененных файлов с 1732 добавлено и 321 удалено
  1. 4 0
      base_requirements.txt
  2. 8 0
      docs/models/dcim/moduletype.md
  3. 40 0
      docs/models/dcim/moduletypeprofile.md
  4. 28 6
      netbox/dcim/api/serializers_/devicetypes.py
  5. 1 0
      netbox/dcim/api/urls.py
  6. 6 0
      netbox/dcim/api/views.py
  7. 29 2
      netbox/dcim/filtersets.py
  8. 30 3
      netbox/dcim/forms/bulk_edit.py
  9. 16 0
      netbox/dcim/forms/bulk_import.py
  10. 15 1
      netbox/dcim/forms/filtersets.py
  11. 87 5
      netbox/dcim/forms/model_forms.py
  12. 6 0
      netbox/dcim/graphql/filters.py
  13. 3 0
      netbox/dcim/graphql/schema.py
  14. 12 0
      netbox/dcim/graphql/types.py
  15. 57 0
      netbox/dcim/migrations/0205_moduletypeprofile.py
  16. 42 0
      netbox/dcim/migrations/0206_load_module_type_profiles.py
  17. 20 0
      netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json
  18. 12 0
      netbox/dcim/migrations/initial_data/module_type_profiles/fan.json
  19. 28 0
      netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json
  20. 29 0
      netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json
  21. 36 0
      netbox/dcim/migrations/initial_data/module_type_profiles/memory.json
  22. 34 0
      netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json
  23. 1 0
      netbox/dcim/models/__init__.py
  24. 2 280
      netbox/dcim/models/devices.py
  25. 360 0
      netbox/dcim/models/modules.py
  26. 11 0
      netbox/dcim/search.py
  27. 47 13
      netbox/dcim/tables/modules.py
  28. 4 0
      netbox/dcim/tables/template_code.py
  29. 65 1
      netbox/dcim/tests/test_api.py
  30. 125 3
      netbox/dcim/tests/test_filtersets.py
  31. 74 0
      netbox/dcim/tests/test_views.py
  32. 3 0
      netbox/dcim/urls.py
  33. 20 0
      netbox/dcim/utils.py
  34. 56 0
      netbox/dcim/views.py
  35. 1 0
      netbox/extras/tests/test_filtersets.py
  36. 17 0
      netbox/netbox/api/fields.py
  37. 32 0
      netbox/netbox/filtersets.py
  38. 1 0
      netbox/netbox/navigation/menu.py
  39. 12 0
      netbox/netbox/tables/columns.py
  40. 22 3
      netbox/templates/dcim/module.html
  41. 25 0
      netbox/templates/dcim/moduletype.html
  42. 59 0
      netbox/templates/dcim/moduletypeprofile.html
  43. 5 3
      netbox/utilities/forms/utils.py
  44. 166 0
      netbox/utilities/jsonschema.py
  45. 62 1
      netbox/utilities/tests/test_forms.py
  46. 18 0
      netbox/utilities/validators.py
  47. 1 0
      requirements.txt

+ 4 - 0
base_requirements.txt

@@ -82,6 +82,10 @@ gunicorn
 # https://jinja.palletsprojects.com/changes/
 Jinja2
 
+# JSON schema validation
+# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst
+jsonschema
+
 # Simple markup language for rendering HTML
 # https://python-markdown.github.io/changelog/
 Markdown

+ 8 - 0
docs/models/dcim/moduletype.md

@@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
 ### Airflow
 
 The direction in which air circulates through the device chassis for cooling.
+
+### Profile
+
+The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.
+
+### Attributes
+
+Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.

+ 40 - 0
docs/models/dcim/moduletypeprofile.md

@@ -0,0 +1,40 @@
+# Module Type Profiles
+
+!!! info "This model was introduced in NetBox v4.3."
+
+Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor.
+
+Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes.
+
+```json
+{
+    "properties": {
+        "type": {
+            "type": "string",
+            "title": "Disk type",
+            "enum": ["HD", "SSD", "NVME"],
+            "default": "HD"
+        },
+        "capacity": {
+            "type": "integer",
+            "title": "Capacity (GB)",
+            "description": "Gross disk size"
+        },
+        "speed": {
+            "type": "integer",
+            "title": "Speed (RPM)"
+        }
+    },
+    "required": [
+        "type", "capacity"
+    ]
+}
+```
+
+The assignment of module types to a profile is optional. The designation of a schema for a profile is also optional: A profile can be used simply as a mechanism for classifying module types if the addition of custom attributes is not needed.
+
+## Fields
+
+### Schema
+
+This field holds the [JSON schema](https://json-schema.org/) for the profile. The configured JSON schema must be valid (or the field must be null).

+ 28 - 6
netbox/dcim/api/serializers_/devicetypes.py

@@ -4,8 +4,8 @@ from django.utils.translation import gettext as _
 from rest_framework import serializers
 
 from dcim.choices import *
-from dcim.models import DeviceType, ModuleType
-from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
+from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer
 from netbox.choices import *
 from .manufacturers import ManufacturerSerializer
@@ -13,6 +13,7 @@ from .platforms import PlatformSerializer
 
 __all__ = (
     'DeviceTypeSerializer',
+    'ModuleTypeProfileSerializer',
     'ModuleTypeSerializer',
 )
 
@@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
         brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
 
 
+class ModuleTypeProfileSerializer(NetBoxModelSerializer):
+
+    class Meta:
+        model = ModuleTypeProfile
+        fields = [
+            'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
+
+
 class ModuleTypeSerializer(NetBoxModelSerializer):
+    profile = ModuleTypeProfileSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
     manufacturer = ManufacturerSerializer(
         nested=True
     )
@@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
         required=False,
         allow_null=True
     )
+    attributes = AttributesField(
+        source='attribute_data',
+        required=False,
+        allow_null=True
+    )
 
     class Meta:
         model = ModuleType
         fields = [
-            'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow',
-            'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated',
+            'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
+            'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated',
         ]
-        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
+        brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')

+ 1 - 0
netbox/dcim/api/urls.py

@@ -21,6 +21,7 @@ router.register('rack-reservations', views.RackReservationViewSet)
 router.register('manufacturers', views.ManufacturerViewSet)
 router.register('device-types', views.DeviceTypeViewSet)
 router.register('module-types', views.ModuleTypeViewSet)
+router.register('module-type-profiles', views.ModuleTypeProfileViewSet)
 
 # Device type components
 router.register('console-port-templates', views.ConsolePortTemplateViewSet)

+ 6 - 0
netbox/dcim/api/views.py

@@ -269,6 +269,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet):
     filterset_class = filtersets.DeviceTypeFilterSet
 
 
+class ModuleTypeProfileViewSet(NetBoxModelViewSet):
+    queryset = ModuleTypeProfile.objects.all()
+    serializer_class = serializers.ModuleTypeProfileSerializer
+    filterset_class = filtersets.ModuleTypeProfileFilterSet
+
+
 class ModuleTypeViewSet(NetBoxModelViewSet):
     queryset = ModuleType.objects.all()
     serializer_class = serializers.ModuleTypeSerializer

+ 29 - 2
netbox/dcim/filtersets.py

@@ -11,7 +11,7 @@ from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
 from netbox.choices import ColorChoices
 from netbox.filtersets import (
-    BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
+    AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
     OrganizationalModelFilterSet,
 )
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
@@ -59,6 +59,7 @@ __all__ = (
     'ModuleBayTemplateFilterSet',
     'ModuleFilterSet',
     'ModuleTypeFilterSet',
+    'ModuleTypeProfileFilterSet',
     'PathEndpointFilterSet',
     'PlatformFilterSet',
     'PowerConnectionFilterSet',
@@ -674,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
         return queryset.exclude(inventoryitemtemplates__isnull=value)
 
 
-class ModuleTypeFilterSet(NetBoxModelFilterSet):
+class ModuleTypeProfileFilterSet(NetBoxModelFilterSet):
+
+    class Meta:
+        model = ModuleTypeProfile
+        fields = ('id', 'name', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        )
+
+
+class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
+    profile_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ModuleTypeProfile.objects.all(),
+        label=_('Profile (ID)'),
+    )
+    profile = django_filters.ModelMultipleChoiceFilter(
+        field_name='profile__name',
+        queryset=ModuleTypeProfile.objects.all(),
+        to_field_name='name',
+        label=_('Profile (name)'),
+    )
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Manufacturer.objects.all(),
         label=_('Manufacturer (ID)'),

+ 30 - 3
netbox/dcim/forms/bulk_edit.py

@@ -14,7 +14,9 @@ from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from users.models import User
 from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
-from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import (
+    ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
+)
 from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
 from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
 from virtualization.models import Cluster
@@ -46,6 +48,7 @@ __all__ = (
     'ModuleBayBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleTypeBulkEditForm',
+    'ModuleTypeProfileBulkEditForm',
     'PlatformBulkEditForm',
     'PowerFeedBulkEditForm',
     'PowerOutletBulkEditForm',
@@ -574,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
 
 
+class ModuleTypeProfileBulkEditForm(NetBoxModelBulkEditForm):
+    schema = JSONField(
+        label=_('Schema'),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+    comments = CommentField()
+
+    model = ModuleTypeProfile
+    fieldsets = (
+        FieldSet('name', 'description', 'schema', name=_('Profile')),
+    )
+    nullable_fields = ('description', 'comments')
+
+
 class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
+    profile = DynamicModelChoiceField(
+        label=_('Profile'),
+        queryset=ModuleTypeProfile.objects.all(),
+        required=False
+    )
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
@@ -609,14 +636,14 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
 
     model = ModuleType
     fieldsets = (
-        FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')),
+        FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')),
         FieldSet(
             'airflow',
             InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
             name=_('Chassis')
         ),
     )
-    nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')
+    nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments')
 
 
 class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):

+ 16 - 0
netbox/dcim/forms/bulk_import.py

@@ -39,6 +39,7 @@ __all__ = (
     'ModuleImportForm',
     'ModuleBayImportForm',
     'ModuleTypeImportForm',
+    'ModuleTypeProfileImportForm',
     'PlatformImportForm',
     'PowerFeedImportForm',
     'PowerOutletImportForm',
@@ -427,7 +428,22 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
         ]
 
 
+class ModuleTypeProfileImportForm(NetBoxModelImportForm):
+
+    class Meta:
+        model = ModuleTypeProfile
+        fields = [
+            'name', 'description', 'schema', 'comments', 'tags',
+        ]
+
+
 class ModuleTypeImportForm(NetBoxModelImportForm):
+    profile = forms.ModelChoiceField(
+        label=_('Profile'),
+        queryset=ModuleTypeProfile.objects.all(),
+        to_field_name='name',
+        required=False
+    )
     manufacturer = forms.ModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),

+ 15 - 1
netbox/dcim/forms/filtersets.py

@@ -39,6 +39,7 @@ __all__ = (
     'ModuleFilterForm',
     'ModuleBayFilterForm',
     'ModuleTypeFilterForm',
+    'ModuleTypeProfileFilterForm',
     'PlatformFilterForm',
     'PowerConnectionFilterForm',
     'PowerFeedFilterForm',
@@ -602,11 +603,19 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
     )
 
 
+class ModuleTypeProfileFilterForm(NetBoxModelFilterSetForm):
+    model = ModuleTypeProfile
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+    )
+    selector_fields = ('filter_id', 'q')
+
+
 class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
     model = ModuleType
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
+        FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
         FieldSet(
             'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
             'pass_through_ports', name=_('Components')
@@ -614,6 +623,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
         FieldSet('weight', 'weight_unit', name=_('Weight')),
     )
     selector_fields = ('filter_id', 'q', 'manufacturer_id')
+    profile_id = DynamicModelMultipleChoiceField(
+        queryset=ModuleTypeProfile.objects.all(),
+        required=False,
+        label=_('Profile')
+    )
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,

+ 87 - 5
netbox/dcim/forms/model_forms.py

@@ -1,5 +1,6 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
+from django.core.validators import EMPTY_VALUES
 from django.utils.translation import gettext_lazy as _
 from timezone_field import TimeZoneFormField
 
@@ -18,6 +19,7 @@ from utilities.forms.fields import (
 )
 from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
 from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
+from utilities.jsonschema import JSONSchemaProperty
 from virtualization.models import Cluster, VMInterface
 from wireless.models import WirelessLAN, WirelessLANGroup
 from .common import InterfaceCommonForm, ModuleCommonForm
@@ -48,6 +50,7 @@ __all__ = (
     'ModuleBayForm',
     'ModuleBayTemplateForm',
     'ModuleTypeForm',
+    'ModuleTypeProfileForm',
     'PlatformForm',
     'PopulateDeviceBayForm',
     'PowerFeedForm',
@@ -404,25 +407,104 @@ class DeviceTypeForm(NetBoxModelForm):
         }
 
 
+class ModuleTypeProfileForm(NetBoxModelForm):
+    schema = JSONField(
+        label=_('Schema'),
+        required=False,
+        help_text=_("Enter a valid JSON schema to define supported attributes.")
+    )
+    comments = CommentField()
+
+    fieldsets = (
+        FieldSet('name', 'description', 'schema', 'tags', name=_('Profile')),
+    )
+
+    class Meta:
+        model = ModuleTypeProfile
+        fields = [
+            'name', 'description', 'schema', 'comments', 'tags',
+        ]
+
+
 class ModuleTypeForm(NetBoxModelForm):
+    profile = forms.ModelChoiceField(
+        queryset=ModuleTypeProfile.objects.all(),
+        label=_('Profile'),
+        required=False,
+        widget=HTMXSelect()
+    )
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all()
     )
     comments = CommentField()
 
-    fieldsets = (
-        FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
-        FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis'))
-    )
+    @property
+    def fieldsets(self):
+        return [
+            FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
+            FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')),
+            FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'))
+        ]
 
     class Meta:
         model = ModuleType
         fields = [
-            'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
+            'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
             'comments', 'tags',
         ]
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Track profile-specific attribute fields
+        self.attr_fields = []
+
+        # Retrieve assigned ModuleTypeProfile, if any
+        if not (profile_id := get_field_value(self, 'profile')):
+            return
+        if not (profile := ModuleTypeProfile.objects.filter(pk=profile_id).first()):
+            return
+
+        # Extend form with fields for profile attributes
+        for attr, form_field in self._get_attr_form_fields(profile).items():
+            field_name = f'attr_{attr}'
+            self.attr_fields.append(field_name)
+            self.fields[field_name] = form_field
+            if self.instance.attribute_data:
+                self.fields[field_name].initial = self.instance.attribute_data.get(attr)
+
+    @staticmethod
+    def _get_attr_form_fields(profile):
+        """
+        Return a dictionary mapping of attribute names to form fields, suitable for extending
+        the form per the selected ModuleTypeProfile.
+        """
+        if not profile.schema:
+            return {}
+
+        properties = profile.schema.get('properties', {})
+        required_fields = profile.schema.get('required', [])
+
+        attr_fields = {}
+        for name, options in properties.items():
+            prop = JSONSchemaProperty(**options)
+            attr_fields[name] = prop.to_form_field(name, required=name in required_fields)
+
+        return dict(sorted(attr_fields.items()))
+
+    def _post_clean(self):
+
+        # Compile attribute data from the individual form fields
+        if self.cleaned_data.get('profile'):
+            self.instance.attribute_data = {
+                name[5:]: self.cleaned_data[name]  # Remove the attr_ prefix
+                for name in self.attr_fields
+                if self.cleaned_data.get(name) not in EMPTY_VALUES
+            }
+
+        return super()._post_clean()
+
 
 class DeviceRoleForm(NetBoxModelForm):
     config_template = DynamicModelChoiceField(

+ 6 - 0
netbox/dcim/graphql/filters.py

@@ -68,6 +68,7 @@ __all__ = (
     'ModuleBayFilter',
     'ModuleBayTemplateFilter',
     'ModuleTypeFilter',
+    'ModuleTypeProfileFilter',
     'PlatformFilter',
     'PowerFeedFilter',
     'PowerOutletFilter',
@@ -559,6 +560,11 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
     position: FilterLookup[str] | None = strawberry_django.filter_field()
 
 
+@strawberry_django.filter(models.ModuleTypeProfile, lookups=True)
+class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
+    name: FilterLookup[str] | None = strawberry_django.filter_field()
+
+
 @strawberry_django.filter(models.ModuleType, lookups=True)
 class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (

+ 3 - 0
netbox/dcim/graphql/schema.py

@@ -77,6 +77,9 @@ class DCIMQuery:
     module_bay_template: ModuleBayTemplateType = strawberry_django.field()
     module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field()
 
+    module_type_profile: ModuleTypeProfileType = strawberry_django.field()
+    module_type_profile_list: List[ModuleTypeProfileType] = strawberry_django.field()
+
     module_type: ModuleTypeType = strawberry_django.field()
     module_type_list: List[ModuleTypeType] = strawberry_django.field()
 

+ 12 - 0
netbox/dcim/graphql/types.py

@@ -61,6 +61,7 @@ __all__ = (
     'ModuleType',
     'ModuleBayType',
     'ModuleBayTemplateType',
+    'ModuleTypeProfileType',
     'ModuleTypeType',
     'PlatformType',
     'PowerFeedType',
@@ -593,6 +594,16 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
     pass
 
 
+@strawberry_django.type(
+    models.ModuleTypeProfile,
+    fields='__all__',
+    filters=ModuleTypeProfileFilter,
+    pagination=True
+)
+class ModuleTypeProfileType(NetBoxObjectType):
+    module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
+
+
 @strawberry_django.type(
     models.ModuleType,
     fields='__all__',
@@ -600,6 +611,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
     pagination=True
 )
 class ModuleTypeType(NetBoxObjectType):
+    profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
 
     frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]

+ 57 - 0
netbox/dcim/migrations/0205_moduletypeprofile.py

@@ -0,0 +1,57 @@
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import utilities.json
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('dcim', '0204_device_role_rebuild'),
+        ('extras', '0125_exporttemplate_file_name'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ModuleTypeProfile',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder),
+                ),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('schema', models.JSONField(blank=True, null=True)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'verbose_name': 'module type profile',
+                'verbose_name_plural': 'module type profiles',
+                'ordering': ('name',),
+            },
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='attribute_data',
+            field=models.JSONField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='moduletype',
+            name='profile',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name='module_types',
+                to='dcim.moduletypeprofile',
+            ),
+        ),
+        migrations.AlterModelOptions(
+            name='moduletype',
+            options={'ordering': ('profile', 'manufacturer', 'model')},
+        ),
+    ]

+ 42 - 0
netbox/dcim/migrations/0206_load_module_type_profiles.py

@@ -0,0 +1,42 @@
+import json
+from pathlib import Path
+
+from django.db import migrations
+
+DATA_FILES_PATH = Path(__file__).parent / 'initial_data' / 'module_type_profiles'
+
+
+def load_initial_data(apps, schema_editor):
+    """
+    Load initial ModuleTypeProfile objects from file.
+    """
+    ModuleTypeProfile = apps.get_model('dcim', 'ModuleTypeProfile')
+    initial_profiles = (
+        'cpu',
+        'fan',
+        'gpu',
+        'hard_disk',
+        'memory',
+        'power_supply'
+    )
+
+    for name in initial_profiles:
+        file_path = DATA_FILES_PATH / f'{name}.json'
+        with file_path.open('r') as f:
+            data = json.load(f)
+            try:
+                ModuleTypeProfile.objects.create(**data)
+            except Exception as e:
+                print(f"Error loading data from {file_path}")
+                raise e
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0205_moduletypeprofile'),
+    ]
+
+    operations = [
+        migrations.RunPython(load_initial_data),
+    ]

+ 20 - 0
netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json

@@ -0,0 +1,20 @@
+{
+    "name": "CPU",
+    "schema": {
+        "properties": {
+            "architecture": {
+                "type": "string",
+                "title": "Architecture"
+            },
+            "speed": {
+                "type": "number",
+                "title": "Speed",
+                "description": "Clock speed in GHz"
+            },
+            "cores": {
+                "type": "integer",
+                "description": "Number of cores present"
+            }
+        }
+    }
+}

+ 12 - 0
netbox/dcim/migrations/initial_data/module_type_profiles/fan.json

@@ -0,0 +1,12 @@
+{
+    "name": "Fan",
+    "schema": {
+        "properties": {
+            "rpm": {
+                "type": "integer",
+                "title": "RPM",
+                "description": "Fan speed (RPM)"
+            }
+        }
+    }
+}

+ 28 - 0
netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json

@@ -0,0 +1,28 @@
+{
+    "name": "GPU",
+    "schema": {
+        "properties": {
+            "interface": {
+                "type": "string",
+                "enum": [
+                    "PCIe 4.0",
+                    "PCIe 4.0 x8",
+                    "PCIe 4.0 x16",
+                    "PCIe 5.0 x16"
+                ]
+            },
+            "gpu" : {
+                "type": "string",
+                "title": "GPU"
+            },
+            "memory": {
+                "type": "integer",
+                "title": "Memory (GB)",
+                "description": "Total memory capacity (in GB)"
+            }
+        },
+        "required": [
+            "memory"
+        ]
+    }
+}

+ 29 - 0
netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json

@@ -0,0 +1,29 @@
+{
+    "name": "Hard disk",
+    "schema": {
+        "properties": {
+            "type": {
+                "type": "string",
+                "title": "Disk type",
+                "enum": [
+                    "HD",
+                    "SSD",
+                    "NVME"
+                ],
+                "default": "SSD"
+            },
+            "size": {
+                "type": "integer",
+                "title": "Size (GB)",
+                "description": "Raw disk capacity"
+            },
+            "speed": {
+                "type": "integer",
+                "title": "Speed (RPM)"
+            }
+        },
+        "required": [
+            "size"
+        ]
+    }
+}

+ 36 - 0
netbox/dcim/migrations/initial_data/module_type_profiles/memory.json

@@ -0,0 +1,36 @@
+{
+    "name": "Memory",
+    "schema": {
+        "properties": {
+            "class": {
+                "type": "string",
+                "title": "Memory class",
+                "enum": [
+                    "DDR3",
+                    "DDR4",
+                    "DDR5"
+                ],
+                "default": "DDR5"
+            },
+            "size": {
+                "type": "integer",
+                "title": "Size (GB)",
+                "description": "Raw capacity of the module"
+            },
+            "data_rate": {
+                "type": "integer",
+                "title": "Data rate",
+                "description": "Speed in MT/s"
+            },
+            "ecc": {
+                "type": "boolean",
+                "title": "ECC",
+                "description": "Error-correcting code is enabled"
+            }
+        },
+        "required": [
+            "class",
+            "size"
+        ]
+    }
+}

+ 34 - 0
netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json

@@ -0,0 +1,34 @@
+{
+    "name": "Power supply",
+    "schema": {
+        "properties": {
+            "input_current": {
+                "type": "string",
+                "title": "Current type",
+                "enum": [
+                    "AC",
+                    "DC"
+                ],
+                "default": "AC"
+            },
+            "input_voltage": {
+                "type": "integer",
+                "title": "Voltage",
+                "default": 120
+            },
+            "wattage": {
+                "type": "integer",
+                "description": "Available output power (watts)"
+            },
+            "hot_swappable": {
+                "type": "boolean",
+                "title": "Hot-swappable",
+                "default": false
+            }
+        },
+        "required": [
+            "input_current",
+            "input_voltage"
+        ]
+    }
+}

+ 1 - 0
netbox/dcim/models/__init__.py

@@ -2,6 +2,7 @@ from .cables import *
 from .device_component_templates import *
 from .device_components import *
 from .devices import *
+from .modules import *
 from .power import *
 from .racks import *
 from .sites import *

+ 2 - 280
netbox/dcim/models/devices.py

@@ -19,6 +19,7 @@ from core.models import ObjectType
 from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
+from dcim.utils import update_interface_bridges
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
@@ -30,6 +31,7 @@ from utilities.fields import ColorField, CounterCacheField
 from utilities.tracking import TrackingModelMixin
 from .device_components import *
 from .mixins import RenderConfigMixin
+from .modules import Module
 
 
 __all__ = (
@@ -38,8 +40,6 @@ __all__ = (
     'DeviceType',
     'MACAddress',
     'Manufacturer',
-    'Module',
-    'ModuleType',
     'Platform',
     'VirtualChassis',
     'VirtualDeviceContext',
@@ -367,103 +367,6 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
 
 
-class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
-    """
-    A ModuleType represents a hardware element that can be installed within a device and which houses additional
-    components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
-    DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It
-    cannot, however house device bays or module bays.
-    """
-    manufacturer = models.ForeignKey(
-        to='dcim.Manufacturer',
-        on_delete=models.PROTECT,
-        related_name='module_types'
-    )
-    model = models.CharField(
-        verbose_name=_('model'),
-        max_length=100
-    )
-    part_number = models.CharField(
-        verbose_name=_('part number'),
-        max_length=50,
-        blank=True,
-        help_text=_('Discrete part number (optional)')
-    )
-    airflow = models.CharField(
-        verbose_name=_('airflow'),
-        max_length=50,
-        choices=ModuleAirflowChoices,
-        blank=True,
-        null=True
-    )
-
-    clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow')
-    prerequisite_models = (
-        'dcim.Manufacturer',
-    )
-
-    class Meta:
-        ordering = ('manufacturer', 'model')
-        constraints = (
-            models.UniqueConstraint(
-                fields=('manufacturer', 'model'),
-                name='%(app_label)s_%(class)s_unique_manufacturer_model'
-            ),
-        )
-        verbose_name = _('module type')
-        verbose_name_plural = _('module types')
-
-    def __str__(self):
-        return self.model
-
-    @property
-    def full_name(self):
-        return f"{self.manufacturer} {self.model}"
-
-    def to_yaml(self):
-        data = {
-            'manufacturer': self.manufacturer.name,
-            'model': self.model,
-            'part_number': self.part_number,
-            'description': self.description,
-            'weight': float(self.weight) if self.weight is not None else None,
-            'weight_unit': self.weight_unit,
-            'comments': self.comments,
-        }
-
-        # Component templates
-        if self.consoleporttemplates.exists():
-            data['console-ports'] = [
-                c.to_yaml() for c in self.consoleporttemplates.all()
-            ]
-        if self.consoleserverporttemplates.exists():
-            data['console-server-ports'] = [
-                c.to_yaml() for c in self.consoleserverporttemplates.all()
-            ]
-        if self.powerporttemplates.exists():
-            data['power-ports'] = [
-                c.to_yaml() for c in self.powerporttemplates.all()
-            ]
-        if self.poweroutlettemplates.exists():
-            data['power-outlets'] = [
-                c.to_yaml() for c in self.poweroutlettemplates.all()
-            ]
-        if self.interfacetemplates.exists():
-            data['interfaces'] = [
-                c.to_yaml() for c in self.interfacetemplates.all()
-            ]
-        if self.frontporttemplates.exists():
-            data['front-ports'] = [
-                c.to_yaml() for c in self.frontporttemplates.all()
-            ]
-        if self.rearporttemplates.exists():
-            data['rear-ports'] = [
-                c.to_yaml() for c in self.rearporttemplates.all()
-            ]
-
-        return yaml.dump(dict(data), sort_keys=False)
-
-
 #
 # Devices
 #
@@ -526,23 +429,6 @@ class Platform(OrganizationalModel):
         verbose_name_plural = _('platforms')
 
 
-def update_interface_bridges(device, interface_templates, module=None):
-    """
-    Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
-    and applies it to the actual interfaces.
-    """
-    for interface_template in interface_templates.exclude(bridge=None):
-        interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
-
-        if interface_template.bridge:
-            interface.bridge = Interface.objects.get(
-                device=device,
-                name=interface_template.bridge.resolve_name(module=module)
-            )
-            interface.full_clean()
-            interface.save()
-
-
 class Device(
     ContactsMixin,
     ImageAttachmentsMixin,
@@ -1155,170 +1041,6 @@ class Device(
         return round(total_weight / 1000, 2)
 
 
-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'
-    )
-    status = models.CharField(
-        verbose_name=_('status'),
-        max_length=50,
-        choices=ModuleStatusChoices,
-        default=ModuleStatusChoices.STATUS_ACTIVE
-    )
-    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')
-    )
-
-    clone_fields = ('device', 'module_type', 'status')
-
-    class Meta:
-        ordering = ('module_bay',)
-        verbose_name = _('module')
-        verbose_name_plural = _('modules')
-
-    def __str__(self):
-        return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
-
-    def get_status_color(self):
-        return ModuleStatusChoices.colors.get(self.status)
-
-    def clean(self):
-        super().clean()
-
-        if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
-            raise ValidationError(
-                _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
-                    device=self.device
-                )
-            )
-
-        # 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
-
-        super().save(*args, **kwargs)
-
-        adopt_components = getattr(self, '_adopt_components', False)
-        disable_replication = getattr(self, '_disable_replication', False)
-
-        # We skip adding components if the module is being edited or
-        # both replication and component adoption is disabled
-        if not is_new or (disable_replication and not adopt_components):
-            return
-
-        # Iterate all component types
-        for templates, component_attribute, component_model in [
-            ("consoleporttemplates", "consoleports", ConsolePort),
-            ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
-            ("interfacetemplates", "interfaces", Interface),
-            ("powerporttemplates", "powerports", PowerPort),
-            ("poweroutlettemplates", "poweroutlets", PowerOutlet),
-            ("rearporttemplates", "rearports", RearPort),
-            ("frontporttemplates", "frontports", FrontPort),
-            ("modulebaytemplates", "modulebays", ModuleBay),
-        ]:
-            create_instances = []
-            update_instances = []
-
-            # Prefetch installed components
-            installed_components = {
-                component.name: component
-                for component in getattr(self.device, component_attribute).filter(module__isnull=True)
-            }
-
-            # Get the template for the module type.
-            for template in getattr(self.module_type, templates).all():
-                template_instance = template.instantiate(device=self.device, module=self)
-
-                if adopt_components:
-                    existing_item = installed_components.get(template_instance.name)
-
-                    # Check if there's a component with the same name already
-                    if existing_item:
-                        # Assign it to the module
-                        existing_item.module = self
-                        update_instances.append(existing_item)
-                        continue
-
-                # Only create new components if replication is enabled
-                if not disable_replication:
-                    create_instances.append(template_instance)
-
-            # Set default values for any applicable custom fields
-            if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
-                for component in create_instances:
-                    component.custom_field_data = cf_defaults
-
-            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)
-            # Emit the post_save signal for each updated object
-            for component in update_instances:
-                post_save.send(
-                    sender=component_model,
-                    instance=component,
-                    created=False,
-                    raw=False,
-                    using='default',
-                    update_fields=update_fields
-                )
-
-        # Interface bridges have to be set after interface instantiation
-        update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
-
-
 #
 # Virtual chassis
 #

+ 360 - 0
netbox/dcim/models/modules.py

@@ -0,0 +1,360 @@
+import jsonschema
+import yaml
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.db.models.signals import post_save
+from django.utils.translation import gettext_lazy as _
+from jsonschema.exceptions import ValidationError as JSONValidationError
+
+from dcim.choices import *
+from dcim.utils import update_interface_bridges
+from extras.models import ConfigContextModel, CustomField
+from netbox.models import PrimaryModel
+from netbox.models.features import ImageAttachmentsMixin
+from netbox.models.mixins import WeightMixin
+from utilities.jsonschema import validate_schema
+from utilities.string import title
+from .device_components import *
+
+__all__ = (
+    'Module',
+    'ModuleType',
+    'ModuleTypeProfile',
+)
+
+
+class ModuleTypeProfile(PrimaryModel):
+    """
+    A profile which defines the attributes which can be set on one or more ModuleTypes.
+    """
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=100,
+        unique=True
+    )
+    schema = models.JSONField(
+        blank=True,
+        null=True,
+        verbose_name=_('schema')
+    )
+
+    clone_fields = ('schema',)
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name = _('module type profile')
+        verbose_name_plural = _('module type profiles')
+
+    def __str__(self):
+        return self.name
+
+    def clean(self):
+        super().clean()
+
+        # Validate the schema definition
+        if self.schema is not None:
+            try:
+                validate_schema(self.schema)
+            except ValidationError as e:
+                raise ValidationError({
+                    'schema': e.message,
+                })
+
+
+class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
+    """
+    A ModuleType represents a hardware element that can be installed within a device and which houses additional
+    components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
+    DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It
+    cannot, however house device bays or module bays.
+    """
+    profile = models.ForeignKey(
+        to='dcim.ModuleTypeProfile',
+        on_delete=models.PROTECT,
+        related_name='module_types',
+        blank=True,
+        null=True
+    )
+    manufacturer = models.ForeignKey(
+        to='dcim.Manufacturer',
+        on_delete=models.PROTECT,
+        related_name='module_types'
+    )
+    model = models.CharField(
+        verbose_name=_('model'),
+        max_length=100
+    )
+    part_number = models.CharField(
+        verbose_name=_('part number'),
+        max_length=50,
+        blank=True,
+        help_text=_('Discrete part number (optional)')
+    )
+    airflow = models.CharField(
+        verbose_name=_('airflow'),
+        max_length=50,
+        choices=ModuleAirflowChoices,
+        blank=True,
+        null=True
+    )
+    attribute_data = models.JSONField(
+        blank=True,
+        null=True,
+        verbose_name=_('attributes')
+    )
+
+    clone_fields = ('profile', 'manufacturer', 'weight', 'weight_unit', 'airflow')
+    prerequisite_models = (
+        'dcim.Manufacturer',
+    )
+
+    class Meta:
+        ordering = ('profile', 'manufacturer', 'model')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('manufacturer', 'model'),
+                name='%(app_label)s_%(class)s_unique_manufacturer_model'
+            ),
+        )
+        verbose_name = _('module type')
+        verbose_name_plural = _('module types')
+
+    def __str__(self):
+        return self.model
+
+    @property
+    def full_name(self):
+        return f"{self.manufacturer} {self.model}"
+
+    @property
+    def attributes(self):
+        """
+        Returns a human-friendly representation of the attributes defined for a ModuleType according to its profile.
+        """
+        if not self.attribute_data or self.profile is None or not self.profile.schema:
+            return {}
+        attrs = {}
+        for name, options in self.profile.schema.get('properties', {}).items():
+            key = options.get('title', title(name))
+            attrs[key] = self.attribute_data.get(name)
+        return dict(sorted(attrs.items()))
+
+    def clean(self):
+        super().clean()
+
+        # Validate any attributes against the assigned profile's schema
+        if self.profile:
+            try:
+                jsonschema.validate(self.attribute_data, schema=self.profile.schema)
+            except JSONValidationError as e:
+                raise ValidationError(_("Invalid schema: {error}").format(error=e))
+        else:
+            self.attribute_data = None
+
+    def to_yaml(self):
+        data = {
+            'profile': self.profile.name if self.profile else None,
+            'manufacturer': self.manufacturer.name,
+            'model': self.model,
+            'part_number': self.part_number,
+            'description': self.description,
+            'weight': float(self.weight) if self.weight is not None else None,
+            'weight_unit': self.weight_unit,
+            'comments': self.comments,
+        }
+
+        # Component templates
+        if self.consoleporttemplates.exists():
+            data['console-ports'] = [
+                c.to_yaml() for c in self.consoleporttemplates.all()
+            ]
+        if self.consoleserverporttemplates.exists():
+            data['console-server-ports'] = [
+                c.to_yaml() for c in self.consoleserverporttemplates.all()
+            ]
+        if self.powerporttemplates.exists():
+            data['power-ports'] = [
+                c.to_yaml() for c in self.powerporttemplates.all()
+            ]
+        if self.poweroutlettemplates.exists():
+            data['power-outlets'] = [
+                c.to_yaml() for c in self.poweroutlettemplates.all()
+            ]
+        if self.interfacetemplates.exists():
+            data['interfaces'] = [
+                c.to_yaml() for c in self.interfacetemplates.all()
+            ]
+        if self.frontporttemplates.exists():
+            data['front-ports'] = [
+                c.to_yaml() for c in self.frontporttemplates.all()
+            ]
+        if self.rearporttemplates.exists():
+            data['rear-ports'] = [
+                c.to_yaml() for c in self.rearporttemplates.all()
+            ]
+
+        return yaml.dump(dict(data), sort_keys=False)
+
+
+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'
+    )
+    status = models.CharField(
+        verbose_name=_('status'),
+        max_length=50,
+        choices=ModuleStatusChoices,
+        default=ModuleStatusChoices.STATUS_ACTIVE
+    )
+    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')
+    )
+
+    clone_fields = ('device', 'module_type', 'status')
+
+    class Meta:
+        ordering = ('module_bay',)
+        verbose_name = _('module')
+        verbose_name_plural = _('modules')
+
+    def __str__(self):
+        return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
+
+    def get_status_color(self):
+        return ModuleStatusChoices.colors.get(self.status)
+
+    def clean(self):
+        super().clean()
+
+        if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
+            raise ValidationError(
+                _("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
+                    device=self.device
+                )
+            )
+
+        # 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
+
+        super().save(*args, **kwargs)
+
+        adopt_components = getattr(self, '_adopt_components', False)
+        disable_replication = getattr(self, '_disable_replication', False)
+
+        # We skip adding components if the module is being edited or
+        # both replication and component adoption is disabled
+        if not is_new or (disable_replication and not adopt_components):
+            return
+
+        # Iterate all component types
+        for templates, component_attribute, component_model in [
+            ("consoleporttemplates", "consoleports", ConsolePort),
+            ("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
+            ("interfacetemplates", "interfaces", Interface),
+            ("powerporttemplates", "powerports", PowerPort),
+            ("poweroutlettemplates", "poweroutlets", PowerOutlet),
+            ("rearporttemplates", "rearports", RearPort),
+            ("frontporttemplates", "frontports", FrontPort),
+            ("modulebaytemplates", "modulebays", ModuleBay),
+        ]:
+            create_instances = []
+            update_instances = []
+
+            # Prefetch installed components
+            installed_components = {
+                component.name: component
+                for component in getattr(self.device, component_attribute).filter(module__isnull=True)
+            }
+
+            # Get the template for the module type.
+            for template in getattr(self.module_type, templates).all():
+                template_instance = template.instantiate(device=self.device, module=self)
+
+                if adopt_components:
+                    existing_item = installed_components.get(template_instance.name)
+
+                    # Check if there's a component with the same name already
+                    if existing_item:
+                        # Assign it to the module
+                        existing_item.module = self
+                        update_instances.append(existing_item)
+                        continue
+
+                # Only create new components if replication is enabled
+                if not disable_replication:
+                    create_instances.append(template_instance)
+
+            # Set default values for any applicable custom fields
+            if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
+                for component in create_instances:
+                    component.custom_field_data = cf_defaults
+
+            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)
+            # Emit the post_save signal for each updated object
+            for component in update_instances:
+                post_save.send(
+                    sender=component_model,
+                    instance=component,
+                    created=False,
+                    raw=False,
+                    using='default',
+                    update_fields=update_fields
+                )
+
+        # Interface bridges have to be set after interface instantiation
+        update_interface_bridges(self.device, self.module_type.interfacetemplates, self)

+ 11 - 0
netbox/dcim/search.py

@@ -183,6 +183,17 @@ class ModuleBayIndex(SearchIndex):
     display_attrs = ('device', 'label', 'position', 'description')
 
 
+@register_search
+class ModuleTypeProfileIndex(SearchIndex):
+    model = models.ModuleTypeProfile
+    fields = (
+        ('name', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('name', 'description')
+
+
 @register_search
 class ModuleTypeIndex(SearchIndex):
     model = models.ModuleType

+ 47 - 13
netbox/dcim/tables/modules.py

@@ -1,25 +1,64 @@
 from django.utils.translation import gettext_lazy as _
 import django_tables2 as tables
 
-from dcim.models import Module, ModuleType
+from dcim.models import Module, ModuleType, ModuleTypeProfile
 from netbox.tables import NetBoxTable, columns
-from .template_code import WEIGHT
+from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT
 
 __all__ = (
     'ModuleTable',
+    'ModuleTypeProfileTable',
     'ModuleTypeTable',
 )
 
 
+class ModuleTypeProfileTable(NetBoxTable):
+    name = tables.Column(
+        verbose_name=_('Name'),
+        linkify=True
+    )
+    attributes = columns.TemplateColumn(
+        template_code=MODULETYPEPROFILE_ATTRIBUTES,
+        accessor=tables.A('schema__properties'),
+        orderable=False,
+        verbose_name=_('Attributes')
+    )
+    comments = columns.MarkdownColumn(
+        verbose_name=_('Comments'),
+    )
+    tags = columns.TagColumn(
+        url_name='dcim:moduletypeprofile_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = ModuleTypeProfile
+        fields = (
+            'pk', 'id', 'name', 'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'name', 'description', 'attributes',
+        )
+
+
 class ModuleTypeTable(NetBoxTable):
-    model = tables.Column(
-        linkify=True,
-        verbose_name=_('Module Type')
+    profile = tables.Column(
+        verbose_name=_('Profile'),
+        linkify=True
     )
     manufacturer = tables.Column(
         verbose_name=_('Manufacturer'),
         linkify=True
     )
+    model = tables.Column(
+        linkify=True,
+        verbose_name=_('Module Type')
+    )
+    weight = columns.TemplateColumn(
+        verbose_name=_('Weight'),
+        template_code=WEIGHT,
+        order_by=('_abs_weight', 'weight_unit')
+    )
+    attributes = columns.DictColumn()
     instance_count = columns.LinkedCountColumn(
         viewname='dcim:module_list',
         url_params={'module_type_id': 'pk'},
@@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable):
     tags = columns.TagColumn(
         url_name='dcim:moduletype_list'
     )
-    weight = columns.TemplateColumn(
-        verbose_name=_('Weight'),
-        template_code=WEIGHT,
-        order_by=('_abs_weight', 'weight_unit')
-    )
 
     class Meta(NetBoxTable.Meta):
         model = ModuleType
         fields = (
-            'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags',
-            'created', 'last_updated',
+            'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
+            'attributes', 'comments', 'tags', 'created', 'last_updated',
         )
         default_columns = (
-            'pk', 'model', 'manufacturer', 'part_number',
+            'pk', 'model', 'profile', 'manufacturer', 'part_number',
         )
 
 

+ 4 - 0
netbox/dcim/tables/template_code.py

@@ -568,3 +568,7 @@ MODULEBAY_BUTTONS = """
     {% endif %}
 {% endif %}
 """
+
+MODULETYPEPROFILE_ATTRIBUTES = """
+{% if value %}{% for attr in value %}{{ attr }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}
+"""

+ 65 - 1
netbox/dcim/tests/test_api.py

@@ -591,7 +591,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
 
 class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
     model = ModuleType
-    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
+    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url']
     bulk_update_data = {
         'part_number': 'ABC123',
     }
@@ -629,6 +629,70 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
         ]
 
 
+class ModuleTypeProfileTest(APIViewTestCases.APIViewTestCase):
+    model = ModuleTypeProfile
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
+    SCHEMAS = [
+        {
+            "properties": {
+                "foo": {
+                    "type": "string"
+                }
+            }
+        },
+        {
+            "properties": {
+                "foo": {
+                    "type": "integer"
+                }
+            }
+        },
+        {
+            "properties": {
+                "foo": {
+                    "type": "boolean"
+                }
+            }
+        },
+    ]
+    create_data = [
+        {
+            'name': 'Module Type Profile 4',
+            'schema': SCHEMAS[0],
+        },
+        {
+            'name': 'Module Type Profile 5',
+            'schema': SCHEMAS[1],
+        },
+        {
+            'name': 'Module Type Profile 6',
+            'schema': SCHEMAS[2],
+        },
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+        'comments': 'New comments',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        module_type_profiles = (
+            ModuleTypeProfile(
+                name='Module Type Profile 1',
+                schema=cls.SCHEMAS[0]
+            ),
+            ModuleTypeProfile(
+                name='Module Type Profile 2',
+                schema=cls.SCHEMAS[1]
+            ),
+            ModuleTypeProfile(
+                name='Module Type Profile 3',
+                schema=cls.SCHEMAS[2]
+            ),
+        )
+        ModuleTypeProfile.objects.bulk_create(module_type_profiles)
+
+
 class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConsolePortTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']

+ 125 - 3
netbox/dcim/tests/test_filtersets.py

@@ -1486,6 +1486,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ModuleType.objects.all()
     filterset = ModuleTypeFilterSet
+    ignore_fields = ['attribute_data']
+
+    PROFILE_SCHEMA = {
+        "properties": {
+            "string": {"type": "string"},
+            "integer": {"type": "integer"},
+            "number": {"type": "number"},
+            "boolean": {"type": "boolean"},
+        }
+    }
 
     @classmethod
     def setUpTestData(cls):
@@ -1496,6 +1506,21 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
             Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
         )
         Manufacturer.objects.bulk_create(manufacturers)
+        module_type_profiles = (
+            ModuleTypeProfile(
+                name='Module Type Profile 1',
+                schema=cls.PROFILE_SCHEMA
+            ),
+            ModuleTypeProfile(
+                name='Module Type Profile 2',
+                schema=cls.PROFILE_SCHEMA
+            ),
+            ModuleTypeProfile(
+                name='Module Type Profile 3',
+                schema=cls.PROFILE_SCHEMA
+            ),
+        )
+        ModuleTypeProfile.objects.bulk_create(module_type_profiles)
 
         module_types = (
             ModuleType(
@@ -1505,7 +1530,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
                 weight=10,
                 weight_unit=WeightUnitChoices.UNIT_POUND,
                 description='foobar1',
-                airflow=ModuleAirflowChoices.FRONT_TO_REAR
+                airflow=ModuleAirflowChoices.FRONT_TO_REAR,
+                profile=module_type_profiles[0],
+                attribute_data={
+                    'string': 'string1',
+                    'integer': 1,
+                    'number': 1.0,
+                    'boolean': True,
+                }
             ),
             ModuleType(
                 manufacturer=manufacturers[1],
@@ -1514,7 +1546,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
                 weight=20,
                 weight_unit=WeightUnitChoices.UNIT_POUND,
                 description='foobar2',
-                airflow=ModuleAirflowChoices.REAR_TO_FRONT
+                airflow=ModuleAirflowChoices.REAR_TO_FRONT,
+                profile=module_type_profiles[1],
+                attribute_data={
+                    'string': 'string2',
+                    'integer': 2,
+                    'number': 2.0,
+                    'boolean_': False,
+                }
             ),
             ModuleType(
                 manufacturer=manufacturers[2],
@@ -1522,7 +1561,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
                 part_number='Part Number 3',
                 weight=30,
                 weight_unit=WeightUnitChoices.UNIT_KILOGRAM,
-                description='foobar3'
+                description='foobar3',
+                profile=module_type_profiles[2],
+                attribute_data={
+                    'string': 'string3',
+                    'integer': 3,
+                    'number': 3.0,
+                    'boolean': None,
+                }
             ),
         )
         ModuleType.objects.bulk_create(module_types)
@@ -1641,6 +1687,82 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'airflow': RackAirflowChoices.FRONT_TO_REAR}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+    def test_profile(self):
+        profiles = ModuleTypeProfile.objects.filter(name__startswith="Module Type Profile")[:2]
+        params = {'profile_id': [profiles[0].pk, profiles[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'profile': [profiles[0].name, profiles[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_profile_attributes(self):
+        params = {'attr_string': 'string1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'attr_integer': '1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'attr_number': '2.0'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'attr_boolean': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
+class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = ModuleTypeProfile.objects.all()
+    filterset = ModuleTypeProfileFilterSet
+    ignore_fields = ['schema']
+
+    SCHEMAS = [
+        {
+            "properties": {
+                "foo": {
+                    "type": "string"
+                }
+            }
+        },
+        {
+            "properties": {
+                "foo": {
+                    "type": "integer"
+                }
+            }
+        },
+        {
+            "properties": {
+                "foo": {
+                    "type": "boolean"
+                }
+            }
+        },
+    ]
+
+    @classmethod
+    def setUpTestData(cls):
+        module_type_profiles = (
+            ModuleTypeProfile(
+                name='Module Type Profile 1',
+                description='foobar1',
+                schema=cls.SCHEMAS[0]
+            ),
+            ModuleTypeProfile(
+                name='Module Type Profile 2',
+                description='foobar2 2',
+                schema=cls.SCHEMAS[1]
+            ),
+            ModuleTypeProfile(
+                name='Module Type Profile 3',
+                description='foobar3',
+                schema=cls.SCHEMAS[2]
+            ),
+        )
+        ModuleTypeProfile.objects.bulk_create(module_type_profiles)
+
+    def test_q(self):
+        params = {'q': 'foobar1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_name(self):
+        params = {'name': ['Module Type Profile 1', 'Module Type Profile 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsolePortTemplate.objects.all()

+ 74 - 0
netbox/dcim/tests/test_views.py

@@ -1,3 +1,4 @@
+import json
 from decimal import Decimal
 from zoneinfo import ZoneInfo
 
@@ -1305,6 +1306,79 @@ front-ports:
         self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
 
 
+class ModuleTypeProfileTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = ModuleTypeProfile
+
+    SCHEMAS = [
+        {
+            "properties": {
+                "foo": {
+                    "type": "string"
+                }
+            }
+        },
+        {
+            "properties": {
+                "foo": {
+                    "type": "integer"
+                }
+            }
+        },
+        {
+            "properties": {
+                "foo": {
+                    "type": "boolean"
+                }
+            }
+        },
+    ]
+
+    @classmethod
+    def setUpTestData(cls):
+        module_type_profiles = (
+            ModuleTypeProfile(
+                name='Module Type Profile 1',
+                schema=cls.SCHEMAS[0]
+            ),
+            ModuleTypeProfile(
+                name='Module Type Profile 2',
+                schema=cls.SCHEMAS[1]
+            ),
+            ModuleTypeProfile(
+                name='Module Type Profile 3',
+                schema=cls.SCHEMAS[2]
+            ),
+        )
+        ModuleTypeProfile.objects.bulk_create(module_type_profiles)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Module Type Profile X',
+            'description': 'A new profile',
+            'schema': json.dumps(cls.SCHEMAS[0]),
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,schema",
+            f"Module Type Profile 4,{json.dumps(cls.SCHEMAS[0])}",
+            f"Module Type Profile 5,{json.dumps(cls.SCHEMAS[1])}",
+            f"Module Type Profile 6,{json.dumps(cls.SCHEMAS[2])}",
+        )
+
+        cls.csv_update_data = (
+            "id,description",
+            f"{module_type_profiles[0].pk},New description",
+            f"{module_type_profiles[1].pk},New description",
+            f"{module_type_profiles[2].pk},New description",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
+
 #
 # DeviceType components
 #

+ 3 - 0
netbox/dcim/urls.py

@@ -37,6 +37,9 @@ urlpatterns = [
     path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))),
     path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
 
+    path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))),
+    path('module-type-profiles/<int:pk>/', include(get_model_urls('dcim', 'moduletypeprofile'))),
+
     path('module-types/', include(get_model_urls('dcim', 'moduletype', detail=False))),
     path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),
 

+ 20 - 0
netbox/dcim/utils.py

@@ -1,3 +1,4 @@
+from django.apps import apps
 from django.contrib.contenttypes.models import ContentType
 from django.db import transaction
 
@@ -56,3 +57,22 @@ def rebuild_paths(terminations):
             for cp in cable_paths:
                 cp.delete()
                 create_cablepath(cp.origins)
+
+
+def update_interface_bridges(device, interface_templates, module=None):
+    """
+    Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
+    and applies it to the actual interfaces.
+    """
+    Interface = apps.get_model('dcim', 'Interface')
+
+    for interface_template in interface_templates.exclude(bridge=None):
+        interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
+
+        if interface_template.bridge:
+            interface.bridge = Interface.objects.get(
+                device=device,
+                name=interface_template.bridge.resolve_name(module=module)
+            )
+            interface.full_clean()
+            interface.save()

+ 56 - 0
netbox/dcim/views.py

@@ -1247,6 +1247,62 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
     table = tables.DeviceTypeTable
 
 
+#
+# Module type profiles
+#
+
+@register_model_view(ModuleTypeProfile, 'list', path='', detail=False)
+class ModuleTypeProfileListView(generic.ObjectListView):
+    queryset = ModuleTypeProfile.objects.annotate(
+        instance_count=count_related(ModuleType, 'profile')
+    )
+    filterset = filtersets.ModuleTypeProfileFilterSet
+    filterset_form = forms.ModuleTypeProfileFilterForm
+    table = tables.ModuleTypeProfileTable
+
+
+@register_model_view(ModuleTypeProfile)
+class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
+    queryset = ModuleTypeProfile.objects.all()
+
+
+@register_model_view(ModuleTypeProfile, 'add', detail=False)
+@register_model_view(ModuleTypeProfile, 'edit')
+class ModuleTypeProfileEditView(generic.ObjectEditView):
+    queryset = ModuleTypeProfile.objects.all()
+    form = forms.ModuleTypeProfileForm
+
+
+@register_model_view(ModuleTypeProfile, 'delete')
+class ModuleTypeProfileDeleteView(generic.ObjectDeleteView):
+    queryset = ModuleTypeProfile.objects.all()
+
+
+@register_model_view(ModuleTypeProfile, 'bulk_import', detail=False)
+class ModuleTypeProfileBulkImportView(generic.BulkImportView):
+    queryset = ModuleTypeProfile.objects.all()
+    model_form = forms.ModuleTypeProfileImportForm
+
+
+@register_model_view(ModuleTypeProfile, 'bulk_edit', path='edit', detail=False)
+class ModuleTypeProfileBulkEditView(generic.BulkEditView):
+    queryset = ModuleTypeProfile.objects.annotate(
+        instance_count=count_related(Module, 'module_type')
+    )
+    filterset = filtersets.ModuleTypeProfileFilterSet
+    table = tables.ModuleTypeProfileTable
+    form = forms.ModuleTypeProfileBulkEditForm
+
+
+@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
+class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
+    queryset = ModuleTypeProfile.objects.annotate(
+        instance_count=count_related(Module, 'module_type')
+    )
+    filterset = filtersets.ModuleTypeProfileFilterSet
+    table = tables.ModuleTypeProfileTable
+
+
 #
 # Module types
 #

+ 1 - 0
netbox/extras/tests/test_filtersets.py

@@ -1161,6 +1161,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'module',
         'modulebay',
         'moduletype',
+        'moduletypeprofile',
         'platform',
         'powerfeed',
         'poweroutlet',

+ 17 - 0
netbox/netbox/api/fields.py

@@ -9,6 +9,7 @@ from rest_framework.exceptions import ValidationError
 from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
 
 __all__ = (
+    'AttributesField',
     'ChoiceField',
     'ContentTypeField',
     'IPNetworkSerializer',
@@ -172,3 +173,19 @@ class IntegerRangeSerializer(serializers.Serializer):
 
     def to_representation(self, instance):
         return instance.lower, instance.upper - 1
+
+
+class AttributesField(serializers.JSONField):
+    """
+    Custom attributes stored as JSON data.
+    """
+    def to_internal_value(self, data):
+        data = super().to_internal_value(data)
+
+        # If updating an object, start with the initial attribute data. This enables the client to modify
+        # individual attributes without having to rewrite the entire field.
+        if data and self.parent.instance:
+            initial_data = getattr(self.parent.instance, self.source, None) or {}
+            return {**initial_data, **data}
+
+        return data

+ 32 - 0
netbox/netbox/filtersets.py

@@ -1,3 +1,5 @@
+import json
+
 import django_filters
 from copy import deepcopy
 from django.contrib.contenttypes.models import ContentType
@@ -20,6 +22,7 @@ from utilities.forms.fields import MACAddressField
 from utilities import filters
 
 __all__ = (
+    'AttributeFiltersMixin',
     'BaseFilterSet',
     'ChangeLoggedModelFilterSet',
     'NetBoxModelFilterSet',
@@ -345,3 +348,32 @@ class NestedGroupModelFilterSet(NetBoxModelFilterSet):
             )
 
         return queryset
+
+
+class AttributeFiltersMixin:
+    attributes_field_name = 'attribute_data'
+    attribute_filter_prefix = 'attr_'
+
+    def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
+        self.attr_filters = {}
+
+        # Extract JSONField-based filters from the incoming data
+        if data is not None:
+            for key, value in data.items():
+                if field := self._get_field_lookup(key):
+                    # Attempt to cast the value to a native JSON type
+                    try:
+                        self.attr_filters[field] = json.loads(value)
+                    except (ValueError, json.JSONDecodeError):
+                        self.attr_filters[field] = value
+
+        super().__init__(data=data, queryset=queryset, request=request, prefix=prefix)
+
+    def _get_field_lookup(self, key):
+        if not key.startswith(self.attribute_filter_prefix):
+            return
+        lookup = key.split(self.attribute_filter_prefix, 1)[1]  # Strip prefix
+        return f'{self.attributes_field_name}__{lookup}'
+
+    def filter_queryset(self, queryset):
+        return super().filter_queryset(queryset).filter(**self.attr_filters)

+ 1 - 0
netbox/netbox/navigation/menu.py

@@ -85,6 +85,7 @@ DEVICES_MENU = Menu(
             items=(
                 get_model_item('dcim', 'devicetype', _('Device Types')),
                 get_model_item('dcim', 'moduletype', _('Module Types')),
+                get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')),
                 get_model_item('dcim', 'manufacturer', _('Manufacturers')),
             ),
         ),

+ 12 - 0
netbox/netbox/tables/columns.py

@@ -35,6 +35,7 @@ __all__ = (
     'ContentTypesColumn',
     'CustomFieldColumn',
     'CustomLinkColumn',
+    'DictColumn',
     'DistanceColumn',
     'DurationColumn',
     'LinkedCountColumn',
@@ -707,3 +708,14 @@ class DistanceColumn(TemplateColumn):
 
     def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
         super().__init__(template_code=template_code, order_by=order_by, **kwargs)
+
+
+class DictColumn(tables.Column):
+    """
+    Render a dictionary of data in a simple key: value format, one pair per line.
+    """
+    def render(self, value):
+        output = '<br />'.join([
+            f'{escape(k)}: {escape(v)}' for k, v in value.items()
+        ])
+        return mark_safe(output)

+ 22 - 3
netbox/templates/dcim/module.html

@@ -1,8 +1,8 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
-{% load tz %}
 {% load i18n %}
+{% load mptt %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -62,8 +62,8 @@
           <td>{{ object.device.device_type|linkify }}</td>
         </tr>
         <tr>
-          <th scope="row">{% trans "Module Type" %}</th>
-          <td>{{ object.module_type|linkify:"full_name" }}</td>
+          <th scope="row">{% trans "Module Bay" %}</th>
+          <td>{% nested_tree object.module_bay %}</td>
         </tr>
         <tr>
           <th scope="row">{% trans "Status" %}</th>
@@ -88,6 +88,25 @@
     {% plugin_left_page object %}
   </div>
   <div class="col col-md-6">
+    <div class="card">
+      <h2 class="card-header">{% trans "Module Type" %}</h2>
+      <table class="table table-hover attr-table">
+        <tr>
+          <th scope="row">{% trans "Manufacturer" %}</th>
+          <td>{{ object.module_type.manufacturer|linkify }}</td>
+        </tr>
+        <tr>
+          <th scope="row">{% trans "Model" %}</th>
+          <td>{{ object.module_type|linkify }}</td>
+        </tr>
+        {% for k, v in object.module_type.attributes.items %}
+          <tr>
+            <th scope="row">{{ k }}</th>
+            <td>{{ v|placeholder }}</td>
+          </tr>
+        {% endfor %}
+      </table>
+    </div>
     {% include 'inc/panels/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% plugin_right_page object %}

+ 25 - 0
netbox/templates/dcim/moduletype.html

@@ -23,6 +23,10 @@
       <div class="card">
         <h2 class="card-header">{% trans "Module Type" %}</h2>
         <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Profile" %}</th>
+            <td>{{ object.profile|linkify|placeholder }}</td>
+          </tr>
           <tr>
             <th scope="row">{% trans "Manufacturer" %}</th>
             <td>{{ object.manufacturer|linkify }}</td>
@@ -60,6 +64,27 @@
       {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Attributes" %}</h2>
+        {% if not object.profile %}
+          <div class="card-body text-muted">
+            {% trans "No profile assigned" %}
+          </div>
+        {% elif object.attributes %}
+          <table class="table table-hover attr-table">
+            {% for k, v in object.attributes.items %}
+              <tr>
+                <th scope="row">{{ k }}</th>
+                <td>{{ v|placeholder }}</td>
+              </tr>
+            {% endfor %}
+          </table>
+        {% else %}
+          <div class="card-body text-muted">
+            {% trans "None" %}
+          </div>
+        {% endif %}
+      </div>
       {% include 'inc/panels/related_objects.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/image_attachments.html' %}

+ 59 - 0
netbox/templates/dcim/moduletypeprofile.html

@@ -0,0 +1,59 @@
+{% extends 'generic/object.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block title %}{{ object.name }}{% endblock %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Module Type Profile" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Name" %}</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header d-flex justify-content-between">
+          {% trans "Schema" %}
+          {% copy_content 'profile_schema' %}
+        </h2>
+        <pre id="profile_schema">{{ object.schema|json }}</pre>
+      </div>
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <h2 class="card-header">
+          {% trans "Module Types" %}
+          {% if perms.dcim.add_moduletype %}
+            <div class="card-actions">
+              <a href="{% url 'dcim:moduletype_add' %}?profile={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
+                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Module Type" %}
+              </a>
+            </div>
+          {% endif %}
+        </h2>
+        {% htmx_table 'dcim:moduletype_list' profile_id=object.pk %}
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 5 - 3
netbox/utilities/forms/utils.py

@@ -136,9 +136,11 @@ def get_field_value(form, field_name):
     """
     field = form.fields[field_name]
 
-    if form.is_bound and (data := form.data.get(field_name)):
-        if hasattr(field, 'valid_value') and field.valid_value(data):
-            return data
+    if form.is_bound and field_name in form.data:
+        if (value := form.data[field_name]) is None:
+            return
+        if hasattr(field, 'valid_value') and field.valid_value(value):
+            return value
 
     return form.get_initial_for_field(field, field_name)
 

+ 166 - 0
netbox/utilities/jsonschema.py

@@ -0,0 +1,166 @@
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any
+
+from django import forms
+from django.contrib.postgres.forms import SimpleArrayField
+from django.core.exceptions import ValidationError
+from django.core.validators import RegexValidator
+from django.utils.translation import gettext_lazy as _
+from jsonschema.exceptions import SchemaError
+from jsonschema.validators import validator_for
+
+from utilities.string import title
+from utilities.validators import MultipleOfValidator
+
+__all__ = (
+    'JSONSchemaProperty',
+    'PropertyTypeEnum',
+    'StringFormatEnum',
+    'validate_schema',
+)
+
+
+class PropertyTypeEnum(Enum):
+    STRING = 'string'
+    INTEGER = 'integer'
+    NUMBER = 'number'
+    BOOLEAN = 'boolean'
+    ARRAY = 'array'
+    OBJECT = 'object'
+
+
+class StringFormatEnum(Enum):
+    EMAIL = 'email'
+    URI = 'uri'
+    IRI = 'iri'
+    UUID = 'uuid'
+    DATE = 'date'
+    TIME = 'time'
+    DATETIME = 'datetime'
+
+
+FORM_FIELDS = {
+    PropertyTypeEnum.STRING.value: forms.CharField,
+    PropertyTypeEnum.INTEGER.value: forms.IntegerField,
+    PropertyTypeEnum.NUMBER.value: forms.FloatField,
+    PropertyTypeEnum.BOOLEAN.value: forms.BooleanField,
+    PropertyTypeEnum.ARRAY.value: SimpleArrayField,
+    PropertyTypeEnum.OBJECT.value: forms.JSONField,
+}
+
+STRING_FORM_FIELDS = {
+    StringFormatEnum.EMAIL.value: forms.EmailField,
+    StringFormatEnum.URI.value: forms.URLField,
+    StringFormatEnum.IRI.value: forms.URLField,
+    StringFormatEnum.UUID.value: forms.UUIDField,
+    StringFormatEnum.DATE.value: forms.DateField,
+    StringFormatEnum.TIME.value: forms.TimeField,
+    StringFormatEnum.DATETIME.value: forms.DateTimeField,
+}
+
+
+@dataclass
+class JSONSchemaProperty:
+    type: PropertyTypeEnum = PropertyTypeEnum.STRING.value
+    title: str | None = None
+    description: str | None = None
+    default: Any = None
+    enum: list | None = None
+
+    # Strings
+    minLength: int | None = None
+    maxLength: int | None = None
+    pattern: str | None = None  # Regex
+    format: StringFormatEnum | None = None
+
+    # Numbers
+    minimum: int | float | None = None
+    maximum: int | float | None = None
+    multipleOf: int | float | None = None
+
+    # Arrays
+    items: dict | None = field(default_factory=dict)
+
+    def to_form_field(self, name, required=False):
+        """
+        Instantiate and return a Django form field suitable for editing the property's value.
+        """
+        field_kwargs = {
+            'label': self.title or title(name),
+            'help_text': self.description,
+            'required': required,
+            'initial': self.default,
+        }
+
+        # Choices
+        if self.enum:
+            choices = [(v, v) for v in self.enum]
+            if not required:
+                choices = [(None, ''), *choices]
+            field_kwargs['choices'] = choices
+
+        # Arrays
+        if self.type == PropertyTypeEnum.ARRAY.value:
+            items_type = self.items.get('type', PropertyTypeEnum.STRING.value)
+            field_kwargs['base_field'] = FORM_FIELDS[items_type]()
+
+        # String validation
+        if self.type == PropertyTypeEnum.STRING.value:
+            if self.minLength is not None:
+                field_kwargs['min_length'] = self.minLength
+            if self.maxLength is not None:
+                field_kwargs['max_length'] = self.maxLength
+            if self.pattern is not None:
+                field_kwargs['validators'] = [
+                    RegexValidator(regex=self.pattern)
+                ]
+
+        # Integer/number validation
+        elif self.type in (PropertyTypeEnum.INTEGER.value, PropertyTypeEnum.NUMBER.value):
+            field_kwargs['widget'] = forms.NumberInput(attrs={'step': 'any'})
+            if self.minimum:
+                field_kwargs['min_value'] = self.minimum
+            if self.maximum:
+                field_kwargs['max_value'] = self.maximum
+            if self.multipleOf:
+                field_kwargs['validators'] = [
+                    MultipleOfValidator(multiple=self.multipleOf)
+                ]
+
+        return self.field_class(**field_kwargs)
+
+    @property
+    def field_class(self):
+        """
+        Resolve the property's type (and string format, if specified) to the appropriate field class.
+        """
+        if self.enum:
+            if self.type == PropertyTypeEnum.ARRAY.value:
+                return forms.MultipleChoiceField
+            return forms.ChoiceField
+        if self.type == PropertyTypeEnum.STRING.value and self.format is not None:
+            try:
+                return STRING_FORM_FIELDS[self.format]
+            except KeyError:
+                raise ValueError(f"Unsupported string format type: {self.format}")
+        try:
+            return FORM_FIELDS[self.type]
+        except KeyError:
+            raise ValueError(f"Unknown property type: {self.type}")
+
+
+def validate_schema(schema):
+    """
+    Check that a minimum JSON schema definition is defined.
+    """
+    # Provide some basic sanity checking (not provided by jsonschema)
+    if not schema or type(schema) is not dict:
+        raise ValidationError(_("Invalid JSON schema definition"))
+    if not schema.get('properties'):
+        raise ValidationError(_("JSON schema must define properties"))
+    try:
+        ValidatorClass = validator_for(schema)
+        ValidatorClass.check_schema(schema)
+    except SchemaError as e:
+        raise ValidationError(_("Invalid JSON schema definition: {error}").format(error=e))

+ 62 - 1
netbox/utilities/tests/test_forms.py

@@ -1,10 +1,11 @@
 from django import forms
 from django.test import TestCase
 
+from dcim.models import Site
 from netbox.choices import ImportFormatChoices
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.forms import BulkRenameForm
-from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
+from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
 
 
 class ExpandIPAddress(TestCase):
@@ -387,3 +388,63 @@ class BulkRenameFormTest(TestCase):
         self.assertTrue(form.is_valid())
         self.assertEqual(form.cleaned_data["find"], " hello ")
         self.assertEqual(form.cleaned_data["replace"], " world ")
+
+
+class GetFieldValueTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        class TestForm(forms.Form):
+            site = forms.ModelChoiceField(
+                queryset=Site.objects.all(),
+                required=False
+            )
+        cls.form_class = TestForm
+
+        cls.sites = (
+            Site(name='Test Site 1', slug='test-site-1'),
+            Site(name='Test Site 2', slug='test-site-2'),
+        )
+        Site.objects.bulk_create(cls.sites)
+
+    def test_unbound_without_initial(self):
+        form = self.form_class()
+        self.assertEqual(
+            get_field_value(form, 'site'),
+            None
+        )
+
+    def test_unbound_with_initial(self):
+        form = self.form_class(initial={'site': self.sites[0].pk})
+        self.assertEqual(
+            get_field_value(form, 'site'),
+            self.sites[0].pk
+        )
+
+    def test_bound_value_without_initial(self):
+        form = self.form_class({'site': self.sites[0].pk})
+        self.assertEqual(
+            get_field_value(form, 'site'),
+            self.sites[0].pk
+        )
+
+    def test_bound_value_with_initial(self):
+        form = self.form_class({'site': self.sites[0].pk}, initial={'site': self.sites[1].pk})
+        self.assertEqual(
+            get_field_value(form, 'site'),
+            self.sites[0].pk
+        )
+
+    def test_bound_null_without_initial(self):
+        form = self.form_class({'site': None})
+        self.assertEqual(
+            get_field_value(form, 'site'),
+            None
+        )
+
+    def test_bound_null_with_initial(self):
+        form = self.form_class({'site': None}, initial={'site': self.sites[1].pk})
+        self.assertEqual(
+            get_field_value(form, 'site'),
+            None
+        )

+ 18 - 0
netbox/utilities/validators.py

@@ -1,3 +1,4 @@
+import decimal
 import re
 
 from django.core.exceptions import ValidationError
@@ -10,6 +11,7 @@ __all__ = (
     'ColorValidator',
     'EnhancedURLValidator',
     'ExclusionValidator',
+    'MultipleOfValidator',
     'validate_regex',
 )
 
@@ -54,6 +56,22 @@ class ExclusionValidator(BaseValidator):
         return a in b
 
 
+class MultipleOfValidator(BaseValidator):
+    """
+    Checks that a field's value is a numeric multiple of the given value. Both values are
+    cast as Decimals for comparison.
+    """
+    def __init__(self, multiple):
+        self.multiple = decimal.Decimal(str(multiple))
+        super().__init__(limit_value=None)
+
+    def __call__(self, value):
+        if decimal.Decimal(str(value)) % self.multiple != 0:
+            raise ValidationError(
+                _("{value} must be a multiple of {multiple}.").format(value=value, multiple=self.multiple)
+            )
+
+
 def validate_regex(value):
     """
     Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex

+ 1 - 0
requirements.txt

@@ -20,6 +20,7 @@ drf-spectacular-sidecar==2025.2.1
 feedparser==6.0.11
 gunicorn==23.0.0
 Jinja2==3.1.5
+jsonschema==4.23.0
 Markdown==3.7
 mkdocs-material==9.6.7
 mkdocstrings[python]==0.28.2