Переглянути джерело

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 місяців тому
батько
коміт
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/
 # https://jinja.palletsprojects.com/changes/
 Jinja2
 Jinja2
 
 
+# JSON schema validation
+# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst
+jsonschema
+
 # Simple markup language for rendering HTML
 # Simple markup language for rendering HTML
 # https://python-markdown.github.io/changelog/
 # https://python-markdown.github.io/changelog/
 Markdown
 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
 ### Airflow
 
 
 The direction in which air circulates through the device chassis for cooling.
 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 rest_framework import serializers
 
 
 from dcim.choices import *
 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.api.serializers import NetBoxModelSerializer
 from netbox.choices import *
 from netbox.choices import *
 from .manufacturers import ManufacturerSerializer
 from .manufacturers import ManufacturerSerializer
@@ -13,6 +13,7 @@ from .platforms import PlatformSerializer
 
 
 __all__ = (
 __all__ = (
     'DeviceTypeSerializer',
     'DeviceTypeSerializer',
+    'ModuleTypeProfileSerializer',
     'ModuleTypeSerializer',
     'ModuleTypeSerializer',
 )
 )
 
 
@@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
         brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
         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):
 class ModuleTypeSerializer(NetBoxModelSerializer):
+    profile = ModuleTypeProfileSerializer(
+        nested=True,
+        required=False,
+        allow_null=True
+    )
     manufacturer = ManufacturerSerializer(
     manufacturer = ManufacturerSerializer(
         nested=True
         nested=True
     )
     )
@@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
         required=False,
         required=False,
         allow_null=True
         allow_null=True
     )
     )
+    attributes = AttributesField(
+        source='attribute_data',
+        required=False,
+        allow_null=True
+    )
 
 
     class Meta:
     class Meta:
         model = ModuleType
         model = ModuleType
         fields = [
         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('manufacturers', views.ManufacturerViewSet)
 router.register('device-types', views.DeviceTypeViewSet)
 router.register('device-types', views.DeviceTypeViewSet)
 router.register('module-types', views.ModuleTypeViewSet)
 router.register('module-types', views.ModuleTypeViewSet)
+router.register('module-type-profiles', views.ModuleTypeProfileViewSet)
 
 
 # Device type components
 # Device type components
 router.register('console-port-templates', views.ConsolePortTemplateViewSet)
 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
     filterset_class = filtersets.DeviceTypeFilterSet
 
 
 
 
+class ModuleTypeProfileViewSet(NetBoxModelViewSet):
+    queryset = ModuleTypeProfile.objects.all()
+    serializer_class = serializers.ModuleTypeProfileSerializer
+    filterset_class = filtersets.ModuleTypeProfileFilterSet
+
+
 class ModuleTypeViewSet(NetBoxModelViewSet):
 class ModuleTypeViewSet(NetBoxModelViewSet):
     queryset = ModuleType.objects.all()
     queryset = ModuleType.objects.all()
     serializer_class = serializers.ModuleTypeSerializer
     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 ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
 from netbox.filtersets import (
 from netbox.filtersets import (
-    BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
+    AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
     OrganizationalModelFilterSet,
     OrganizationalModelFilterSet,
 )
 )
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
@@ -59,6 +59,7 @@ __all__ = (
     'ModuleBayTemplateFilterSet',
     'ModuleBayTemplateFilterSet',
     'ModuleFilterSet',
     'ModuleFilterSet',
     'ModuleTypeFilterSet',
     'ModuleTypeFilterSet',
+    'ModuleTypeProfileFilterSet',
     'PathEndpointFilterSet',
     'PathEndpointFilterSet',
     'PlatformFilterSet',
     'PlatformFilterSet',
     'PowerConnectionFilterSet',
     'PowerConnectionFilterSet',
@@ -674,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
         return queryset.exclude(inventoryitemtemplates__isnull=value)
         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(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         label=_('Manufacturer (ID)'),
         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 tenancy.models import Tenant
 from users.models import User
 from users.models import User
 from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
 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.rendering import FieldSet, InlineFields, TabbedGroups
 from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
 from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
 from virtualization.models import Cluster
 from virtualization.models import Cluster
@@ -46,6 +48,7 @@ __all__ = (
     'ModuleBayBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleTypeBulkEditForm',
     'ModuleTypeBulkEditForm',
+    'ModuleTypeProfileBulkEditForm',
     'PlatformBulkEditForm',
     'PlatformBulkEditForm',
     'PowerFeedBulkEditForm',
     'PowerFeedBulkEditForm',
     'PowerOutletBulkEditForm',
     'PowerOutletBulkEditForm',
@@ -574,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
     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):
 class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
+    profile = DynamicModelChoiceField(
+        label=_('Profile'),
+        queryset=ModuleTypeProfile.objects.all(),
+        required=False
+    )
     manufacturer = DynamicModelChoiceField(
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -609,14 +636,14 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = ModuleType
     model = ModuleType
     fieldsets = (
     fieldsets = (
-        FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')),
+        FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')),
         FieldSet(
         FieldSet(
             'airflow',
             'airflow',
             InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
             InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
             name=_('Chassis')
             name=_('Chassis')
         ),
         ),
     )
     )
-    nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')
+    nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments')
 
 
 
 
 class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
 class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):

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

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

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

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

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

@@ -1,5 +1,6 @@
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.core.validators import EMPTY_VALUES
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from timezone_field import TimeZoneFormField
 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.rendering import FieldSet, InlineFields, TabbedGroups
 from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
 from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
+from utilities.jsonschema import JSONSchemaProperty
 from virtualization.models import Cluster, VMInterface
 from virtualization.models import Cluster, VMInterface
 from wireless.models import WirelessLAN, WirelessLANGroup
 from wireless.models import WirelessLAN, WirelessLANGroup
 from .common import InterfaceCommonForm, ModuleCommonForm
 from .common import InterfaceCommonForm, ModuleCommonForm
@@ -48,6 +50,7 @@ __all__ = (
     'ModuleBayForm',
     'ModuleBayForm',
     'ModuleBayTemplateForm',
     'ModuleBayTemplateForm',
     'ModuleTypeForm',
     'ModuleTypeForm',
+    'ModuleTypeProfileForm',
     'PlatformForm',
     'PlatformForm',
     'PopulateDeviceBayForm',
     'PopulateDeviceBayForm',
     'PowerFeedForm',
     '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):
 class ModuleTypeForm(NetBoxModelForm):
+    profile = forms.ModelChoiceField(
+        queryset=ModuleTypeProfile.objects.all(),
+        label=_('Profile'),
+        required=False,
+        widget=HTMXSelect()
+    )
     manufacturer = DynamicModelChoiceField(
     manufacturer = DynamicModelChoiceField(
         label=_('Manufacturer'),
         label=_('Manufacturer'),
         queryset=Manufacturer.objects.all()
         queryset=Manufacturer.objects.all()
     )
     )
     comments = CommentField()
     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:
     class Meta:
         model = ModuleType
         model = ModuleType
         fields = [
         fields = [
-            'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
+            'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
             'comments', 'tags',
             '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):
 class DeviceRoleForm(NetBoxModelForm):
     config_template = DynamicModelChoiceField(
     config_template = DynamicModelChoiceField(

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

@@ -68,6 +68,7 @@ __all__ = (
     'ModuleBayFilter',
     'ModuleBayFilter',
     'ModuleBayTemplateFilter',
     'ModuleBayTemplateFilter',
     'ModuleTypeFilter',
     'ModuleTypeFilter',
+    'ModuleTypeProfileFilter',
     'PlatformFilter',
     'PlatformFilter',
     'PowerFeedFilter',
     'PowerFeedFilter',
     'PowerOutletFilter',
     'PowerOutletFilter',
@@ -559,6 +560,11 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
     position: FilterLookup[str] | None = strawberry_django.filter_field()
     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)
 @strawberry_django.filter(models.ModuleType, lookups=True)
 class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
 class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
     manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     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: ModuleBayTemplateType = strawberry_django.field()
     module_bay_template_list: List[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: ModuleTypeType = strawberry_django.field()
     module_type_list: List[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',
     'ModuleType',
     'ModuleBayType',
     'ModuleBayType',
     'ModuleBayTemplateType',
     'ModuleBayTemplateType',
+    'ModuleTypeProfileType',
     'ModuleTypeType',
     'ModuleTypeType',
     'PlatformType',
     'PlatformType',
     'PowerFeedType',
     'PowerFeedType',
@@ -593,6 +594,16 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
     pass
     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(
 @strawberry_django.type(
     models.ModuleType,
     models.ModuleType,
     fields='__all__',
     fields='__all__',
@@ -600,6 +611,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
     pagination=True
     pagination=True
 )
 )
 class ModuleTypeType(NetBoxObjectType):
 class ModuleTypeType(NetBoxObjectType):
+    profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
     manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
 
 
     frontporttemplates: List[Annotated["FrontPortTemplateType", 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_component_templates import *
 from .device_components import *
 from .device_components import *
 from .devices import *
 from .devices import *
+from .modules import *
 from .power import *
 from .power import *
 from .racks import *
 from .racks import *
 from .sites 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.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
 from dcim.fields import MACAddressField
+from dcim.utils import update_interface_bridges
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
@@ -30,6 +31,7 @@ from utilities.fields import ColorField, CounterCacheField
 from utilities.tracking import TrackingModelMixin
 from utilities.tracking import TrackingModelMixin
 from .device_components import *
 from .device_components import *
 from .mixins import RenderConfigMixin
 from .mixins import RenderConfigMixin
+from .modules import Module
 
 
 
 
 __all__ = (
 __all__ = (
@@ -38,8 +40,6 @@ __all__ = (
     'DeviceType',
     'DeviceType',
     'MACAddress',
     'MACAddress',
     'Manufacturer',
     'Manufacturer',
-    'Module',
-    'ModuleType',
     'Platform',
     'Platform',
     'VirtualChassis',
     'VirtualChassis',
     'VirtualDeviceContext',
     'VirtualDeviceContext',
@@ -367,103 +367,6 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
         return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
         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
 # Devices
 #
 #
@@ -526,23 +429,6 @@ class Platform(OrganizationalModel):
         verbose_name_plural = _('platforms')
         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(
 class Device(
     ContactsMixin,
     ContactsMixin,
     ImageAttachmentsMixin,
     ImageAttachmentsMixin,
@@ -1155,170 +1041,6 @@ class Device(
         return round(total_weight / 1000, 2)
         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
 # 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')
     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
 @register_search
 class ModuleTypeIndex(SearchIndex):
 class ModuleTypeIndex(SearchIndex):
     model = models.ModuleType
     model = models.ModuleType

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

@@ -1,25 +1,64 @@
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 import django_tables2 as tables
 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 netbox.tables import NetBoxTable, columns
-from .template_code import WEIGHT
+from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT
 
 
 __all__ = (
 __all__ = (
     'ModuleTable',
     'ModuleTable',
+    'ModuleTypeProfileTable',
     'ModuleTypeTable',
     '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):
 class ModuleTypeTable(NetBoxTable):
-    model = tables.Column(
-        linkify=True,
-        verbose_name=_('Module Type')
+    profile = tables.Column(
+        verbose_name=_('Profile'),
+        linkify=True
     )
     )
     manufacturer = tables.Column(
     manufacturer = tables.Column(
         verbose_name=_('Manufacturer'),
         verbose_name=_('Manufacturer'),
         linkify=True
         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(
     instance_count = columns.LinkedCountColumn(
         viewname='dcim:module_list',
         viewname='dcim:module_list',
         url_params={'module_type_id': 'pk'},
         url_params={'module_type_id': 'pk'},
@@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable):
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:moduletype_list'
         url_name='dcim:moduletype_list'
     )
     )
-    weight = columns.TemplateColumn(
-        verbose_name=_('Weight'),
-        template_code=WEIGHT,
-        order_by=('_abs_weight', 'weight_unit')
-    )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = ModuleType
         model = ModuleType
         fields = (
         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 = (
         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 %}
 {% 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):
 class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
     model = ModuleType
     model = ModuleType
-    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
+    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url']
     bulk_update_data = {
     bulk_update_data = {
         'part_number': 'ABC123',
         '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):
 class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConsolePortTemplate
     model = ConsolePortTemplate
     brief_fields = ['description', 'display', 'id', 'name', 'url']
     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):
 class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ModuleType.objects.all()
     queryset = ModuleType.objects.all()
     filterset = ModuleTypeFilterSet
     filterset = ModuleTypeFilterSet
+    ignore_fields = ['attribute_data']
+
+    PROFILE_SCHEMA = {
+        "properties": {
+            "string": {"type": "string"},
+            "integer": {"type": "integer"},
+            "number": {"type": "number"},
+            "boolean": {"type": "boolean"},
+        }
+    }
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1496,6 +1506,21 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
             Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
             Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
         )
         )
         Manufacturer.objects.bulk_create(manufacturers)
         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 = (
         module_types = (
             ModuleType(
             ModuleType(
@@ -1505,7 +1530,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
                 weight=10,
                 weight=10,
                 weight_unit=WeightUnitChoices.UNIT_POUND,
                 weight_unit=WeightUnitChoices.UNIT_POUND,
                 description='foobar1',
                 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(
             ModuleType(
                 manufacturer=manufacturers[1],
                 manufacturer=manufacturers[1],
@@ -1514,7 +1546,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
                 weight=20,
                 weight=20,
                 weight_unit=WeightUnitChoices.UNIT_POUND,
                 weight_unit=WeightUnitChoices.UNIT_POUND,
                 description='foobar2',
                 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(
             ModuleType(
                 manufacturer=manufacturers[2],
                 manufacturer=manufacturers[2],
@@ -1522,7 +1561,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
                 part_number='Part Number 3',
                 part_number='Part Number 3',
                 weight=30,
                 weight=30,
                 weight_unit=WeightUnitChoices.UNIT_KILOGRAM,
                 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)
         ModuleType.objects.bulk_create(module_types)
@@ -1641,6 +1687,82 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'airflow': RackAirflowChoices.FRONT_TO_REAR}
         params = {'airflow': RackAirflowChoices.FRONT_TO_REAR}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         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):
 class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = ConsolePortTemplate.objects.all()
     queryset = ConsolePortTemplate.objects.all()

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

@@ -1,3 +1,4 @@
+import json
 from decimal import Decimal
 from decimal import Decimal
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
 
 
@@ -1305,6 +1306,79 @@ front-ports:
         self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
         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
 # 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/', include(get_model_urls('dcim', 'devicetype', detail=False))),
     path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
     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/', include(get_model_urls('dcim', 'moduletype', detail=False))),
     path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),
     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.contrib.contenttypes.models import ContentType
 from django.db import transaction
 from django.db import transaction
 
 
@@ -56,3 +57,22 @@ def rebuild_paths(terminations):
             for cp in cable_paths:
             for cp in cable_paths:
                 cp.delete()
                 cp.delete()
                 create_cablepath(cp.origins)
                 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
     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
 # Module types
 #
 #

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

@@ -1161,6 +1161,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'module',
         'module',
         'modulebay',
         'modulebay',
         'moduletype',
         'moduletype',
+        'moduletypeprofile',
         'platform',
         'platform',
         'powerfeed',
         'powerfeed',
         'poweroutlet',
         '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
 from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
 
 
 __all__ = (
 __all__ = (
+    'AttributesField',
     'ChoiceField',
     'ChoiceField',
     'ContentTypeField',
     'ContentTypeField',
     'IPNetworkSerializer',
     'IPNetworkSerializer',
@@ -172,3 +173,19 @@ class IntegerRangeSerializer(serializers.Serializer):
 
 
     def to_representation(self, instance):
     def to_representation(self, instance):
         return instance.lower, instance.upper - 1
         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
 import django_filters
 from copy import deepcopy
 from copy import deepcopy
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -20,6 +22,7 @@ from utilities.forms.fields import MACAddressField
 from utilities import filters
 from utilities import filters
 
 
 __all__ = (
 __all__ = (
+    'AttributeFiltersMixin',
     'BaseFilterSet',
     'BaseFilterSet',
     'ChangeLoggedModelFilterSet',
     'ChangeLoggedModelFilterSet',
     'NetBoxModelFilterSet',
     'NetBoxModelFilterSet',
@@ -345,3 +348,32 @@ class NestedGroupModelFilterSet(NetBoxModelFilterSet):
             )
             )
 
 
         return queryset
         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=(
             items=(
                 get_model_item('dcim', 'devicetype', _('Device Types')),
                 get_model_item('dcim', 'devicetype', _('Device Types')),
                 get_model_item('dcim', 'moduletype', _('Module Types')),
                 get_model_item('dcim', 'moduletype', _('Module Types')),
+                get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')),
                 get_model_item('dcim', 'manufacturer', _('Manufacturers')),
                 get_model_item('dcim', 'manufacturer', _('Manufacturers')),
             ),
             ),
         ),
         ),

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

@@ -35,6 +35,7 @@ __all__ = (
     'ContentTypesColumn',
     'ContentTypesColumn',
     'CustomFieldColumn',
     'CustomFieldColumn',
     'CustomLinkColumn',
     'CustomLinkColumn',
+    'DictColumn',
     'DistanceColumn',
     'DistanceColumn',
     'DurationColumn',
     'DurationColumn',
     'LinkedCountColumn',
     'LinkedCountColumn',
@@ -707,3 +708,14 @@ class DistanceColumn(TemplateColumn):
 
 
     def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
     def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
         super().__init__(template_code=template_code, order_by=order_by, **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' %}
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load helpers %}
 {% load plugins %}
 {% load plugins %}
-{% load tz %}
 {% load i18n %}
 {% load i18n %}
+{% load mptt %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
@@ -62,8 +62,8 @@
           <td>{{ object.device.device_type|linkify }}</td>
           <td>{{ object.device.device_type|linkify }}</td>
         </tr>
         </tr>
         <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>
         <tr>
         <tr>
           <th scope="row">{% trans "Status" %}</th>
           <th scope="row">{% trans "Status" %}</th>
@@ -88,6 +88,25 @@
     {% plugin_left_page object %}
     {% plugin_left_page object %}
   </div>
   </div>
   <div class="col col-md-6">
   <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/related_objects.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% include 'inc/panels/custom_fields.html' %}
     {% plugin_right_page object %}
     {% plugin_right_page object %}

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

@@ -23,6 +23,10 @@
       <div class="card">
       <div class="card">
         <h2 class="card-header">{% trans "Module Type" %}</h2>
         <h2 class="card-header">{% trans "Module Type" %}</h2>
         <table class="table table-hover attr-table">
         <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Profile" %}</th>
+            <td>{{ object.profile|linkify|placeholder }}</td>
+          </tr>
           <tr>
           <tr>
             <th scope="row">{% trans "Manufacturer" %}</th>
             <th scope="row">{% trans "Manufacturer" %}</th>
             <td>{{ object.manufacturer|linkify }}</td>
             <td>{{ object.manufacturer|linkify }}</td>
@@ -60,6 +64,27 @@
       {% plugin_left_page object %}
       {% plugin_left_page object %}
     </div>
     </div>
     <div class="col col-md-6">
     <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/related_objects.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/custom_fields.html' %}
       {% include 'inc/panels/image_attachments.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]
     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)
     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 import forms
 from django.test import TestCase
 from django.test import TestCase
 
 
+from dcim.models import Site
 from netbox.choices import ImportFormatChoices
 from netbox.choices import ImportFormatChoices
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.bulk_import import BulkImportForm
 from utilities.forms.forms import BulkRenameForm
 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):
 class ExpandIPAddress(TestCase):
@@ -387,3 +388,63 @@ class BulkRenameFormTest(TestCase):
         self.assertTrue(form.is_valid())
         self.assertTrue(form.is_valid())
         self.assertEqual(form.cleaned_data["find"], " hello ")
         self.assertEqual(form.cleaned_data["find"], " hello ")
         self.assertEqual(form.cleaned_data["replace"], " world ")
         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
 import re
 
 
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
@@ -10,6 +11,7 @@ __all__ = (
     'ColorValidator',
     'ColorValidator',
     'EnhancedURLValidator',
     'EnhancedURLValidator',
     'ExclusionValidator',
     'ExclusionValidator',
+    'MultipleOfValidator',
     'validate_regex',
     'validate_regex',
 )
 )
 
 
@@ -54,6 +56,22 @@ class ExclusionValidator(BaseValidator):
         return a in b
         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):
 def validate_regex(value):
     """
     """
     Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex
     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
 feedparser==6.0.11
 gunicorn==23.0.0
 gunicorn==23.0.0
 Jinja2==3.1.5
 Jinja2==3.1.5
+jsonschema==4.23.0
 Markdown==3.7
 Markdown==3.7
 mkdocs-material==9.6.7
 mkdocs-material==9.6.7
 mkdocstrings[python]==0.28.2
 mkdocstrings[python]==0.28.2