Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
65ad972
Move Module & ModuleType models to a separate file
jeremystretch Mar 25, 2025
678e6f1
Add ModuleTypeProfile & related fields
jeremystretch Mar 25, 2025
69e67d0
Initial work on JSON schema validation
jeremystretch Mar 25, 2025
3cda074
Add attributes property on ModuleType
jeremystretch Mar 26, 2025
93bd2ee
Introduce MultipleOfValidator
jeremystretch Mar 26, 2025
a67ea13
Introduce JSONSchemaProperty
jeremystretch Mar 26, 2025
5f70732
Enable dynamic form field rendering
jeremystretch Mar 26, 2025
ad9c5ca
Misc cleanup
jeremystretch Mar 26, 2025
c1ff89e
Fix migration conflict
jeremystretch Mar 26, 2025
0d2a8b8
Ensure deterministic ordering of attriubte fields
jeremystretch Mar 26, 2025
dc46cb2
Support choices & default values
jeremystretch Mar 26, 2025
56d163e
Include module type attributes on module view
jeremystretch Mar 27, 2025
4fd3a91
Enable modifying individual attributes via REST API
jeremystretch Mar 28, 2025
94b3aae
Enable filtering by attribute values
jeremystretch Mar 28, 2025
7edc67e
Add documentation & tests
jeremystretch Mar 28, 2025
1be9209
Schema should be optional
jeremystretch Mar 28, 2025
9f16e43
Include attributes column for profiles
jeremystretch Mar 28, 2025
9bc2ad4
Profile is nullable
jeremystretch Mar 28, 2025
5de244f
Include some initial profiles to be installed via migration
jeremystretch Mar 28, 2025
96462e9
Fix migrations conflict
jeremystretch Mar 28, 2025
668cfaf
Fix filterset test
jeremystretch Mar 28, 2025
5c85805
Misc cleanup
jeremystretch Mar 31, 2025
9ee9cc7
Fixes #19023: get_field_value() should respect null values in bound f…
jeremystretch Mar 31, 2025
26efdde
Skip filters which do not specify a JSON-serializable value
jeremystretch Apr 1, 2025
4c98b0f
Fix handling of array item types
jeremystretch Apr 1, 2025
f1092d3
Fix initial data in schema field during bulk edit
jeremystretch Apr 1, 2025
ef43bdf
Implement sanity checking for JSON schema definitions
jeremystretch Apr 1, 2025
550152d
Fall back to filtering by string value
jeremystretch Apr 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions base_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ gunicorn
# https://jinja.palletsprojects.com/changes/
Jinja2

# JSON schema validation
# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst
jsonschema

# Simple markup language for rendering HTML
# https://python-markdown.github.io/changelog/
Markdown
Expand Down
8 changes: 8 additions & 0 deletions docs/models/dcim/moduletype.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
### Airflow

The direction in which air circulates through the device chassis for cooling.

### Profile

The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.

### Attributes

Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.
40 changes: 40 additions & 0 deletions docs/models/dcim/moduletypeprofile.md
Original file line number Diff line number Diff line change
@@ -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).
34 changes: 28 additions & 6 deletions netbox/dcim/api/serializers_/devicetypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@
from rest_framework import serializers

from dcim.choices import *
from dcim.models import DeviceType, ModuleType
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from netbox.choices import *
from .manufacturers import ManufacturerSerializer
from .platforms import PlatformSerializer

__all__ = (
'DeviceTypeSerializer',
'ModuleTypeProfileSerializer',
'ModuleTypeSerializer',
)

Expand Down Expand Up @@ -62,7 +63,23 @@ class Meta:
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')


class ModuleTypeProfileSerializer(NetBoxModelSerializer):

class Meta:
model = ModuleTypeProfile
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')


class ModuleTypeSerializer(NetBoxModelSerializer):
profile = ModuleTypeProfileSerializer(
nested=True,
required=False,
allow_null=True
)
manufacturer = ManufacturerSerializer(
nested=True
)
Expand All @@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
required=False,
allow_null=True
)
attributes = AttributesField(
source='attribute_data',
required=False,
allow_null=True
)

class Meta:
model = ModuleType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')
1 change: 1 addition & 0 deletions netbox/dcim/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
router.register('manufacturers', views.ManufacturerViewSet)
router.register('device-types', views.DeviceTypeViewSet)
router.register('module-types', views.ModuleTypeViewSet)
router.register('module-type-profiles', views.ModuleTypeProfileViewSet)

# Device type components
router.register('console-port-templates', views.ConsolePortTemplateViewSet)
Expand Down
6 changes: 6 additions & 0 deletions netbox/dcim/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet):
filterset_class = filtersets.DeviceTypeFilterSet


class ModuleTypeProfileViewSet(NetBoxModelViewSet):
queryset = ModuleTypeProfile.objects.all()
serializer_class = serializers.ModuleTypeProfileSerializer
filterset_class = filtersets.ModuleTypeProfileFilterSet


class ModuleTypeViewSet(NetBoxModelViewSet):
queryset = ModuleType.objects.all()
serializer_class = serializers.ModuleTypeSerializer
Expand Down
31 changes: 29 additions & 2 deletions netbox/dcim/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
from netbox.choices import ColorChoices
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
OrganizationalModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
Expand Down Expand Up @@ -59,6 +59,7 @@
'ModuleBayTemplateFilterSet',
'ModuleFilterSet',
'ModuleTypeFilterSet',
'ModuleTypeProfileFilterSet',
'PathEndpointFilterSet',
'PlatformFilterSet',
'PowerConnectionFilterSet',
Expand Down Expand Up @@ -674,7 +675,33 @@ def _inventory_items(self, queryset, name, 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(
queryset=Manufacturer.objects.all(),
label=_('Manufacturer (ID)'),
Expand Down
33 changes: 30 additions & 3 deletions netbox/dcim/forms/bulk_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
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
Expand Down Expand Up @@ -46,6 +48,7 @@
'ModuleBayBulkEditForm',
'ModuleBayTemplateBulkEditForm',
'ModuleTypeBulkEditForm',
'ModuleTypeProfileBulkEditForm',
'PlatformBulkEditForm',
'PowerFeedBulkEditForm',
'PowerOutletBulkEditForm',
Expand Down Expand Up @@ -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
)
Copy link
Member

@jnovinger jnovinger Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that schema values of null and "" don't affect any change on the items selected. However, I noticed that {} does essentially clear the schemas of the selected items.

This might be the intended behavior, but was not super intuitive.

Resolved

Copy link
Member

@jnovinger jnovinger Mar 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relatedly, true and false literals were accepted as valid input for this field, even though they don't necessarily make sense.

Perhaps values should be locked down to some representation of no schema (null or {} perhaps) and some schema that is at least of type object. Not sure, though, just thinking out loud.

Resolved

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(),
Expand Down Expand Up @@ -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):
Expand Down
16 changes: 16 additions & 0 deletions netbox/dcim/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
'ModuleImportForm',
'ModuleBayImportForm',
'ModuleTypeImportForm',
'ModuleTypeProfileImportForm',
'PlatformImportForm',
'PowerFeedImportForm',
'PowerOutletImportForm',
Expand Down Expand Up @@ -427,7 +428,22 @@ class Meta:
]


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(),
Expand Down
16 changes: 15 additions & 1 deletion netbox/dcim/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
'ModuleFilterForm',
'ModuleBayFilterForm',
'ModuleTypeFilterForm',
'ModuleTypeProfileFilterForm',
'PlatformFilterForm',
'PowerConnectionFilterForm',
'PowerFeedFilterForm',
Expand Down Expand Up @@ -602,18 +603,31 @@ 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')
),
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,
Expand Down
Loading