From 5f707320ea16416d8d88bbc1193c5d8684058ec3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 26 Mar 2025 15:11:12 -0400 Subject: [PATCH] Enable dynamic form field rendering --- netbox/dcim/forms/model_forms.py | 71 ++++++++++++++++++++++++++++---- netbox/dcim/models/modules.py | 4 +- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 1a16142d0..a0833b0be 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -18,6 +18,7 @@ from utilities.forms.fields import ( ) from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK +from utilities.jsonschema import JSONSchemaProperty from virtualization.models import Cluster, VMInterface from wireless.models import WirelessLAN, WirelessLANGroup from .common import InterfaceCommonForm, ModuleCommonForm @@ -423,10 +424,11 @@ class ModuleTypeProfileForm(NetBoxModelForm): class ModuleTypeForm(NetBoxModelForm): - profile = DynamicModelChoiceField( - label=_('Profile'), + profile = forms.ModelChoiceField( queryset=ModuleTypeProfile.objects.all(), - required=False + label=_('Profile'), + required=False, + widget=HTMXSelect() ) manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), @@ -434,19 +436,70 @@ class ModuleTypeForm(NetBoxModelForm): ) comments = CommentField() - fieldsets = ( - FieldSet('profile', 'manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), - FieldSet('attribute_data', name=_('Profile Attributes')), - FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), - ) + @property + def fieldsets(self): + return [ + FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')), + FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')), + FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes')) + ] class Meta: model = ModuleType fields = [ 'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', - 'attribute_data', '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 attr_fields + + def _post_clean(self): + + # Compile attribute data from the individual form fields + self.instance.attribute_data = { + name[5:]: self.cleaned_data[name] # Remove the attr_ prefix + for name in self.attr_fields if self.cleaned_data[name] is not None + } + + return super()._post_clean() + class DeviceRoleForm(NetBoxModelForm): config_template = DynamicModelChoiceField( diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py index 6029eccd3..1881d045e 100644 --- a/netbox/dcim/models/modules.py +++ b/netbox/dcim/models/modules.py @@ -143,9 +143,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): try: jsonschema.validate(self.attribute_data, schema=self.profile.schema) except JSONValidationError as e: - raise ValidationError({ - 'attributes': _("Invalid schema: {error}").format(error=e) - }) + raise ValidationError(_("Invalid schema: {error}").format(error=e)) else: self.attribute_data = None