Skip to content

Commit 8d7889e

Browse files
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
1 parent 864db46 commit 8d7889e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1732
-321
lines changed

base_requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ gunicorn
8282
# https://jinja.palletsprojects.com/changes/
8383
Jinja2
8484

85+
# JSON schema validation
86+
# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst
87+
jsonschema
88+
8589
# Simple markup language for rendering HTML
8690
# https://python-markdown.github.io/changelog/
8791
Markdown

docs/models/dcim/moduletype.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
4343
### Airflow
4444

4545
The direction in which air circulates through the device chassis for cooling.
46+
47+
### Profile
48+
49+
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.
50+
51+
### Attributes
52+
53+
Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.

docs/models/dcim/moduletypeprofile.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Module Type Profiles
2+
3+
!!! info "This model was introduced in NetBox v4.3."
4+
5+
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.
6+
7+
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.
8+
9+
```json
10+
{
11+
"properties": {
12+
"type": {
13+
"type": "string",
14+
"title": "Disk type",
15+
"enum": ["HD", "SSD", "NVME"],
16+
"default": "HD"
17+
},
18+
"capacity": {
19+
"type": "integer",
20+
"title": "Capacity (GB)",
21+
"description": "Gross disk size"
22+
},
23+
"speed": {
24+
"type": "integer",
25+
"title": "Speed (RPM)"
26+
}
27+
},
28+
"required": [
29+
"type", "capacity"
30+
]
31+
}
32+
```
33+
34+
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.
35+
36+
## Fields
37+
38+
### Schema
39+
40+
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).

netbox/dcim/api/serializers_/devicetypes.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44
from rest_framework import serializers
55

66
from dcim.choices import *
7-
from dcim.models import DeviceType, ModuleType
8-
from netbox.api.fields import ChoiceField, RelatedObjectCountField
7+
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
8+
from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
99
from netbox.api.serializers import NetBoxModelSerializer
1010
from netbox.choices import *
1111
from .manufacturers import ManufacturerSerializer
1212
from .platforms import PlatformSerializer
1313

1414
__all__ = (
1515
'DeviceTypeSerializer',
16+
'ModuleTypeProfileSerializer',
1617
'ModuleTypeSerializer',
1718
)
1819

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

6465

66+
class ModuleTypeProfileSerializer(NetBoxModelSerializer):
67+
68+
class Meta:
69+
model = ModuleTypeProfile
70+
fields = [
71+
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields',
72+
'created', 'last_updated',
73+
]
74+
brief_fields = ('id', 'url', 'display', 'name', 'description')
75+
76+
6577
class ModuleTypeSerializer(NetBoxModelSerializer):
78+
profile = ModuleTypeProfileSerializer(
79+
nested=True,
80+
required=False,
81+
allow_null=True
82+
)
6683
manufacturer = ManufacturerSerializer(
6784
nested=True
6885
)
@@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
7895
required=False,
7996
allow_null=True
8097
)
98+
attributes = AttributesField(
99+
source='attribute_data',
100+
required=False,
101+
allow_null=True
102+
)
81103

82104
class Meta:
83105
model = ModuleType
84106
fields = [
85-
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow',
86-
'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields',
87-
'created', 'last_updated',
107+
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
108+
'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
109+
'last_updated',
88110
]
89-
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
111+
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')

netbox/dcim/api/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
router.register('manufacturers', views.ManufacturerViewSet)
2222
router.register('device-types', views.DeviceTypeViewSet)
2323
router.register('module-types', views.ModuleTypeViewSet)
24+
router.register('module-type-profiles', views.ModuleTypeProfileViewSet)
2425

2526
# Device type components
2627
router.register('console-port-templates', views.ConsolePortTemplateViewSet)

netbox/dcim/api/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet):
269269
filterset_class = filtersets.DeviceTypeFilterSet
270270

271271

272+
class ModuleTypeProfileViewSet(NetBoxModelViewSet):
273+
queryset = ModuleTypeProfile.objects.all()
274+
serializer_class = serializers.ModuleTypeProfileSerializer
275+
filterset_class = filtersets.ModuleTypeProfileFilterSet
276+
277+
272278
class ModuleTypeViewSet(NetBoxModelViewSet):
273279
queryset = ModuleType.objects.all()
274280
serializer_class = serializers.ModuleTypeSerializer

netbox/dcim/filtersets.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
1212
from netbox.choices import ColorChoices
1313
from netbox.filtersets import (
14-
BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
14+
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
1515
OrganizationalModelFilterSet,
1616
)
1717
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
@@ -59,6 +59,7 @@
5959
'ModuleBayTemplateFilterSet',
6060
'ModuleFilterSet',
6161
'ModuleTypeFilterSet',
62+
'ModuleTypeProfileFilterSet',
6263
'PathEndpointFilterSet',
6364
'PlatformFilterSet',
6465
'PowerConnectionFilterSet',
@@ -674,7 +675,33 @@ def _inventory_items(self, queryset, name, value):
674675
return queryset.exclude(inventoryitemtemplates__isnull=value)
675676

676677

677-
class ModuleTypeFilterSet(NetBoxModelFilterSet):
678+
class ModuleTypeProfileFilterSet(NetBoxModelFilterSet):
679+
680+
class Meta:
681+
model = ModuleTypeProfile
682+
fields = ('id', 'name', 'description')
683+
684+
def search(self, queryset, name, value):
685+
if not value.strip():
686+
return queryset
687+
return queryset.filter(
688+
Q(name__icontains=value) |
689+
Q(description__icontains=value) |
690+
Q(comments__icontains=value)
691+
)
692+
693+
694+
class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
695+
profile_id = django_filters.ModelMultipleChoiceFilter(
696+
queryset=ModuleTypeProfile.objects.all(),
697+
label=_('Profile (ID)'),
698+
)
699+
profile = django_filters.ModelMultipleChoiceFilter(
700+
field_name='profile__name',
701+
queryset=ModuleTypeProfile.objects.all(),
702+
to_field_name='name',
703+
label=_('Profile (name)'),
704+
)
678705
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
679706
queryset=Manufacturer.objects.all(),
680707
label=_('Manufacturer (ID)'),

netbox/dcim/forms/bulk_edit.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
from tenancy.models import Tenant
1515
from users.models import User
1616
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
17-
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
17+
from utilities.forms.fields import (
18+
ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
19+
)
1820
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
1921
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
2022
from virtualization.models import Cluster
@@ -46,6 +48,7 @@
4648
'ModuleBayBulkEditForm',
4749
'ModuleBayTemplateBulkEditForm',
4850
'ModuleTypeBulkEditForm',
51+
'ModuleTypeProfileBulkEditForm',
4952
'PlatformBulkEditForm',
5053
'PowerFeedBulkEditForm',
5154
'PowerOutletBulkEditForm',
@@ -574,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
574577
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
575578

576579

580+
class ModuleTypeProfileBulkEditForm(NetBoxModelBulkEditForm):
581+
schema = JSONField(
582+
label=_('Schema'),
583+
required=False
584+
)
585+
description = forms.CharField(
586+
label=_('Description'),
587+
max_length=200,
588+
required=False
589+
)
590+
comments = CommentField()
591+
592+
model = ModuleTypeProfile
593+
fieldsets = (
594+
FieldSet('name', 'description', 'schema', name=_('Profile')),
595+
)
596+
nullable_fields = ('description', 'comments')
597+
598+
577599
class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
600+
profile = DynamicModelChoiceField(
601+
label=_('Profile'),
602+
queryset=ModuleTypeProfile.objects.all(),
603+
required=False
604+
)
578605
manufacturer = DynamicModelChoiceField(
579606
label=_('Manufacturer'),
580607
queryset=Manufacturer.objects.all(),
@@ -609,14 +636,14 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
609636

610637
model = ModuleType
611638
fieldsets = (
612-
FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')),
639+
FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')),
613640
FieldSet(
614641
'airflow',
615642
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
616643
name=_('Chassis')
617644
),
618645
)
619-
nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')
646+
nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments')
620647

621648

622649
class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):

netbox/dcim/forms/bulk_import.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
'ModuleImportForm',
4040
'ModuleBayImportForm',
4141
'ModuleTypeImportForm',
42+
'ModuleTypeProfileImportForm',
4243
'PlatformImportForm',
4344
'PowerFeedImportForm',
4445
'PowerOutletImportForm',
@@ -427,7 +428,22 @@ class Meta:
427428
]
428429

429430

431+
class ModuleTypeProfileImportForm(NetBoxModelImportForm):
432+
433+
class Meta:
434+
model = ModuleTypeProfile
435+
fields = [
436+
'name', 'description', 'schema', 'comments', 'tags',
437+
]
438+
439+
430440
class ModuleTypeImportForm(NetBoxModelImportForm):
441+
profile = forms.ModelChoiceField(
442+
label=_('Profile'),
443+
queryset=ModuleTypeProfile.objects.all(),
444+
to_field_name='name',
445+
required=False
446+
)
431447
manufacturer = forms.ModelChoiceField(
432448
label=_('Manufacturer'),
433449
queryset=Manufacturer.objects.all(),

netbox/dcim/forms/filtersets.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
'ModuleFilterForm',
4040
'ModuleBayFilterForm',
4141
'ModuleTypeFilterForm',
42+
'ModuleTypeProfileFilterForm',
4243
'PlatformFilterForm',
4344
'PowerConnectionFilterForm',
4445
'PowerFeedFilterForm',
@@ -602,18 +603,31 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
602603
)
603604

604605

606+
class ModuleTypeProfileFilterForm(NetBoxModelFilterSetForm):
607+
model = ModuleTypeProfile
608+
fieldsets = (
609+
FieldSet('q', 'filter_id', 'tag'),
610+
)
611+
selector_fields = ('filter_id', 'q')
612+
613+
605614
class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
606615
model = ModuleType
607616
fieldsets = (
608617
FieldSet('q', 'filter_id', 'tag'),
609-
FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
618+
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
610619
FieldSet(
611620
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
612621
'pass_through_ports', name=_('Components')
613622
),
614623
FieldSet('weight', 'weight_unit', name=_('Weight')),
615624
)
616625
selector_fields = ('filter_id', 'q', 'manufacturer_id')
626+
profile_id = DynamicModelMultipleChoiceField(
627+
queryset=ModuleTypeProfile.objects.all(),
628+
required=False,
629+
label=_('Profile')
630+
)
617631
manufacturer_id = DynamicModelMultipleChoiceField(
618632
queryset=Manufacturer.objects.all(),
619633
required=False,

0 commit comments

Comments
 (0)