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
This commit is contained in:
Jeremy Stretch
2025-04-01 13:05:06 -04:00
committed by GitHub
parent 864db469ba
commit 8d7889e2c0
47 changed files with 1732 additions and 321 deletions

View File

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

View File

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

View File

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

View File

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