diff --git a/base_requirements.txt b/base_requirements.txt index 6921f2d49..7eaa9d928 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -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 diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index 7077e16c2..88f04466a 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -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. diff --git a/docs/models/dcim/moduletypeprofile.md b/docs/models/dcim/moduletypeprofile.md new file mode 100644 index 000000000..80345c82b --- /dev/null +++ b/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). diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 0ce2af2f8..61e3833ec 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -4,8 +4,8 @@ from django.utils.translation import gettext as _ 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 @@ -13,6 +13,7 @@ from .platforms import PlatformSerializer __all__ = ( 'DeviceTypeSerializer', + 'ModuleTypeProfileSerializer', 'ModuleTypeSerializer', ) @@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer): 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 ) @@ -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') diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fc3740374..734ac13db 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -21,6 +21,7 @@ router.register('rack-reservations', views.RackReservationViewSet) 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) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d7dbbef91..575ee770f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -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 diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 2c8b77f57..fed660c1f 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -11,7 +11,7 @@ from ipam.filtersets import PrimaryIPFilterSet 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 @@ -59,6 +59,7 @@ __all__ = ( 'ModuleBayTemplateFilterSet', 'ModuleFilterSet', 'ModuleTypeFilterSet', + 'ModuleTypeProfileFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -674,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): 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)'), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index a77c7fa9c..dd6c4a791 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -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): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 081e9d41d..d412694b3 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -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(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index b70661348..8465f6404 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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, diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 2829ac754..ac9b0ea9a 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -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( diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 4203517cc..5dfc0d73c 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -68,6 +68,7 @@ __all__ = ( 'ModuleBayFilter', 'ModuleBayTemplateFilter', 'ModuleTypeFilter', + 'ModuleTypeProfileFilter', 'PlatformFilter', 'PowerFeedFilter', 'PowerOutletFilter', @@ -559,6 +560,11 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin): 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) class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin): manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 011a2b58b..1b0661bc2 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -77,6 +77,9 @@ class DCIMQuery: module_bay_template: 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_list: List[ModuleTypeType] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 24fa16263..fb8c136ad 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -61,6 +61,7 @@ __all__ = ( 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', + 'ModuleTypeProfileType', 'ModuleTypeType', 'PlatformType', 'PowerFeedType', @@ -593,6 +594,16 @@ class ModuleBayTemplateType(ModularComponentTemplateType): 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( models.ModuleType, fields='__all__', @@ -600,6 +611,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType): pagination=True ) class ModuleTypeType(NetBoxObjectType): + profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/migrations/0205_moduletypeprofile.py b/netbox/dcim/migrations/0205_moduletypeprofile.py new file mode 100644 index 000000000..3e9a4d6d8 --- /dev/null +++ b/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')}, + ), + ] diff --git a/netbox/dcim/migrations/0206_load_module_type_profiles.py b/netbox/dcim/migrations/0206_load_module_type_profiles.py new file mode 100644 index 000000000..e3ca7d27a --- /dev/null +++ b/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), + ] diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/cpu.json new file mode 100644 index 000000000..255886c5e --- /dev/null +++ b/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" + } + } + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json b/netbox/dcim/migrations/initial_data/module_type_profiles/fan.json new file mode 100644 index 000000000..e6a2a384e --- /dev/null +++ b/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)" + } + } + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json b/netbox/dcim/migrations/initial_data/module_type_profiles/gpu.json new file mode 100644 index 000000000..1725a4ab7 --- /dev/null +++ b/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" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json b/netbox/dcim/migrations/initial_data/module_type_profiles/hard_disk.json new file mode 100644 index 000000000..8d55cfde6 --- /dev/null +++ b/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" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json b/netbox/dcim/migrations/initial_data/module_type_profiles/memory.json new file mode 100644 index 000000000..8346bfce9 --- /dev/null +++ b/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" + ] + } +} diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json b/netbox/dcim/migrations/initial_data/module_type_profiles/power_supply.json new file mode 100644 index 000000000..ea060a889 --- /dev/null +++ b/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" + ] + } +} diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index d74f34828..33af25678 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2,6 +2,7 @@ from .cables import * from .device_component_templates import * from .device_components import * from .devices import * +from .modules import * from .power import * from .racks import * from .sites import * diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 76269f5c9..ae6d9c316 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -19,6 +19,7 @@ from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField +from dcim.utils import update_interface_bridges from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.choices import ColorChoices @@ -30,6 +31,7 @@ from utilities.fields import ColorField, CounterCacheField from utilities.tracking import TrackingModelMixin from .device_components import * from .mixins import RenderConfigMixin +from .modules import Module __all__ = ( @@ -38,8 +40,6 @@ __all__ = ( 'DeviceType', 'MACAddress', 'Manufacturer', - 'Module', - 'ModuleType', 'Platform', 'VirtualChassis', 'VirtualDeviceContext', @@ -367,103 +367,6 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): 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 # @@ -526,23 +429,6 @@ class Platform(OrganizationalModel): 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( ContactsMixin, ImageAttachmentsMixin, @@ -1155,170 +1041,6 @@ class Device( 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 # diff --git a/netbox/dcim/models/modules.py b/netbox/dcim/models/modules.py new file mode 100644 index 000000000..432b7afd1 --- /dev/null +++ b/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) diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index a85005679..33b666df7 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -183,6 +183,17 @@ class ModuleBayIndex(SearchIndex): 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 class ModuleTypeIndex(SearchIndex): model = models.ModuleType diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 6bd0d53b5..52edea8b4 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,25 +1,64 @@ from django.utils.translation import gettext_lazy as _ 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 .template_code import WEIGHT +from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT __all__ = ( 'ModuleTable', + 'ModuleTypeProfileTable', '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): - model = tables.Column( - linkify=True, - verbose_name=_('Module Type') + profile = tables.Column( + verbose_name=_('Profile'), + linkify=True ) manufacturer = tables.Column( verbose_name=_('Manufacturer'), 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( viewname='dcim:module_list', url_params={'module_type_id': 'pk'}, @@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable): tags = columns.TagColumn( url_name='dcim:moduletype_list' ) - weight = columns.TemplateColumn( - verbose_name=_('Weight'), - template_code=WEIGHT, - order_by=('_abs_weight', 'weight_unit') - ) class Meta(NetBoxTable.Meta): model = ModuleType 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 = ( - 'pk', 'model', 'manufacturer', 'part_number', + 'pk', 'model', 'profile', 'manufacturer', 'part_number', ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 225237ec4..3c4ca7bd4 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -568,3 +568,7 @@ MODULEBAY_BUTTONS = """ {% endif %} {% endif %} """ + +MODULETYPEPROFILE_ATTRIBUTES = """ +{% if value %}{% for attr in value %}{{ attr }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %} +""" diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c2a7660c6..8007d9161 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -591,7 +591,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ModuleTypeTest(APIViewTestCases.APIViewTestCase): model = ModuleType - brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url'] + brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url'] bulk_update_data = { '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): model = ConsolePortTemplate brief_fields = ['description', 'display', 'id', 'name', 'url'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index b2353b4ba..b56ea3919 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1486,6 +1486,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ModuleType.objects.all() filterset = ModuleTypeFilterSet + ignore_fields = ['attribute_data'] + + PROFILE_SCHEMA = { + "properties": { + "string": {"type": "string"}, + "integer": {"type": "integer"}, + "number": {"type": "number"}, + "boolean": {"type": "boolean"}, + } + } @classmethod def setUpTestData(cls): @@ -1496,6 +1506,21 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ) 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 = ( ModuleType( @@ -1505,7 +1530,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): weight=10, weight_unit=WeightUnitChoices.UNIT_POUND, 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( manufacturer=manufacturers[1], @@ -1514,7 +1546,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): weight=20, weight_unit=WeightUnitChoices.UNIT_POUND, 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( manufacturer=manufacturers[2], @@ -1522,7 +1561,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): part_number='Part Number 3', weight=30, 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) @@ -1641,6 +1687,82 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'airflow': RackAirflowChoices.FRONT_TO_REAR} 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): queryset = ConsolePortTemplate.objects.all() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 0bf8fefb3..3c43d1834 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,3 +1,4 @@ +import json from decimal import Decimal from zoneinfo import ZoneInfo @@ -1305,6 +1306,79 @@ front-ports: 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 # diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index bcfd32707..122593834 100644 --- a/netbox/dcim/urls.py +++ b/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'))), + path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))), + path('module-type-profiles//', 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'))), diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 4d4228490..0931761bf 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.db import transaction @@ -56,3 +57,22 @@ def rebuild_paths(terminations): for cp in cable_paths: cp.delete() 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() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4b2f22035..2027a7368 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1247,6 +1247,62 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): 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 # diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 84d7aad5a..247653472 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1161,6 +1161,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'module', 'modulebay', 'moduletype', + 'moduletypeprofile', 'platform', 'powerfeed', 'poweroutlet', diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index e7d1ef574..db5ec184d 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -9,6 +9,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.relations import PrimaryKeyRelatedField, RelatedField __all__ = ( + 'AttributesField', 'ChoiceField', 'ContentTypeField', 'IPNetworkSerializer', @@ -172,3 +173,19 @@ class IntegerRangeSerializer(serializers.Serializer): def to_representation(self, instance): 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 diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index d80b07e90..ffae9b7ff 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -1,3 +1,5 @@ +import json + import django_filters from copy import deepcopy from django.contrib.contenttypes.models import ContentType @@ -20,6 +22,7 @@ from utilities.forms.fields import MACAddressField from utilities import filters __all__ = ( + 'AttributeFiltersMixin', 'BaseFilterSet', 'ChangeLoggedModelFilterSet', 'NetBoxModelFilterSet', @@ -345,3 +348,32 @@ class NestedGroupModelFilterSet(NetBoxModelFilterSet): ) 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) diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 9148caa8e..778f0d67c 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -85,6 +85,7 @@ DEVICES_MENU = Menu( items=( get_model_item('dcim', 'devicetype', _('Device Types')), get_model_item('dcim', 'moduletype', _('Module Types')), + get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')), get_model_item('dcim', 'manufacturer', _('Manufacturers')), ), ), diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index cf6e1f133..f0e55176d 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -35,6 +35,7 @@ __all__ = ( 'ContentTypesColumn', 'CustomFieldColumn', 'CustomLinkColumn', + 'DictColumn', 'DistanceColumn', 'DurationColumn', 'LinkedCountColumn', @@ -707,3 +708,14 @@ class DistanceColumn(TemplateColumn): def __init__(self, template_code=template_code, order_by='_abs_distance', **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 = '
'.join([ + f'{escape(k)}: {escape(v)}' for k, v in value.items() + ]) + return mark_safe(output) diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index f702c6608..3f09ec82f 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -1,8 +1,8 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} -{% load tz %} {% load i18n %} +{% load mptt %} {% block breadcrumbs %} {{ block.super }} @@ -62,8 +62,8 @@ {{ object.device.device_type|linkify }} - {% trans "Module Type" %} - {{ object.module_type|linkify:"full_name" }} + {% trans "Module Bay" %} + {% nested_tree object.module_bay %} {% trans "Status" %} @@ -88,6 +88,25 @@ {% plugin_left_page object %}
+
+

{% trans "Module Type" %}

+ + + + + + + + + + {% for k, v in object.module_type.attributes.items %} + + + + + {% endfor %} +
{% trans "Manufacturer" %}{{ object.module_type.manufacturer|linkify }}
{% trans "Model" %}{{ object.module_type|linkify }}
{{ k }}{{ v|placeholder }}
+
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index b3d53e09b..adc5e2a98 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -23,6 +23,10 @@

{% trans "Module Type" %}

+ + + + @@ -60,6 +64,27 @@ {% plugin_left_page object %}
+
+

{% trans "Attributes" %}

+ {% if not object.profile %} +
+ {% trans "No profile assigned" %} +
+ {% elif object.attributes %} +
{% trans "Profile" %}{{ object.profile|linkify|placeholder }}
{% trans "Manufacturer" %} {{ object.manufacturer|linkify }}
+ {% for k, v in object.attributes.items %} + + + + + {% endfor %} +
{{ k }}{{ v|placeholder }}
+ {% else %} +
+ {% trans "None" %} +
+ {% endif %} +
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/image_attachments.html' %} diff --git a/netbox/templates/dcim/moduletypeprofile.html b/netbox/templates/dcim/moduletypeprofile.html new file mode 100644 index 000000000..87e576bda --- /dev/null +++ b/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 %} +
+
+
+

{% trans "Module Type Profile" %}

+ + + + + + + + + +
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+
+

+ {% trans "Schema" %} + {% copy_content 'profile_schema' %} +

+
{{ object.schema|json }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+

+ {% trans "Module Types" %} + {% if perms.dcim.add_moduletype %} + + {% endif %} +

+ {% htmx_table 'dcim:moduletype_list' profile_id=object.pk %} +
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 0429fe571..b8985c6b0 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -136,9 +136,11 @@ def get_field_value(form, 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) diff --git a/netbox/utilities/jsonschema.py b/netbox/utilities/jsonschema.py new file mode 100644 index 000000000..724253a50 --- /dev/null +++ b/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)) diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index a0592f626..8ec1404d5 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -1,10 +1,11 @@ from django import forms from django.test import TestCase +from dcim.models import Site from netbox.choices import ImportFormatChoices from utilities.forms.bulk_import import BulkImportForm 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): @@ -387,3 +388,63 @@ class BulkRenameFormTest(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data["find"], " hello ") 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 + ) diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index 0e896e52a..4b7529472 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,3 +1,4 @@ +import decimal import re from django.core.exceptions import ValidationError @@ -10,6 +11,7 @@ __all__ = ( 'ColorValidator', 'EnhancedURLValidator', 'ExclusionValidator', + 'MultipleOfValidator', 'validate_regex', ) @@ -54,6 +56,22 @@ class ExclusionValidator(BaseValidator): 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): """ Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex diff --git a/requirements.txt b/requirements.txt index 210a0b1d8..06e0e1617 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ drf-spectacular-sidecar==2025.2.1 feedparser==6.0.11 gunicorn==23.0.0 Jinja2==3.1.5 +jsonschema==4.23.0 Markdown==3.7 mkdocs-material==9.6.7 mkdocstrings[python]==0.28.2