mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-13 16:47:34 -06:00
* Move Module & ModuleType models to a separate file * Add ModuleTypeProfile & related fields * Initial work on JSON schema validation * Add attributes property on ModuleType * Introduce MultipleOfValidator * Introduce JSONSchemaProperty * Enable dynamic form field rendering * Misc cleanup * Fix migration conflict * Ensure deterministic ordering of attriubte fields * Support choices & default values * Include module type attributes on module view * Enable modifying individual attributes via REST API * Enable filtering by attribute values * Add documentation & tests * Schema should be optional * Include attributes column for profiles * Profile is nullable * Include some initial profiles to be installed via migration * Fix migrations conflict * Fix filterset test * Misc cleanup * Fixes #19023: get_field_value() should respect null values in bound forms (#19024) * Skip filters which do not specify a JSON-serializable value * Fix handling of array item types * Fix initial data in schema field during bulk edit * Implement sanity checking for JSON schema definitions * Fall back to filtering by string value
This commit is contained in:
parent
864db469ba
commit
8d7889e2c0
@ -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
|
||||
|
@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
|
||||
### Airflow
|
||||
|
||||
The direction in which air circulates through the device chassis for cooling.
|
||||
|
||||
### Profile
|
||||
|
||||
The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.
|
||||
|
||||
### Attributes
|
||||
|
||||
Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.
|
||||
|
40
docs/models/dcim/moduletypeprofile.md
Normal file
40
docs/models/dcim/moduletypeprofile.md
Normal file
@ -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).
|
@ -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')
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)'),
|
||||
|
@ -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):
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -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(
|
||||
|
@ -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 = (
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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')]]
|
||||
|
57
netbox/dcim/migrations/0205_moduletypeprofile.py
Normal file
57
netbox/dcim/migrations/0205_moduletypeprofile.py
Normal file
@ -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')},
|
||||
),
|
||||
]
|
42
netbox/dcim/migrations/0206_load_module_type_profiles.py
Normal file
42
netbox/dcim/migrations/0206_load_module_type_profiles.py
Normal file
@ -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),
|
||||
]
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Fan",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"rpm": {
|
||||
"type": "integer",
|
||||
"title": "RPM",
|
||||
"description": "Fan speed (RPM)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
@ -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 *
|
||||
|
@ -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
|
||||
#
|
||||
|
360
netbox/dcim/models/modules.py
Normal file
360
netbox/dcim/models/modules.py
Normal file
@ -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)
|
@ -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
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -568,3 +568,7 @@ MODULEBAY_BUTTONS = """
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
MODULETYPEPROFILE_ATTRIBUTES = """
|
||||
{% if value %}{% for attr in value %}{{ attr }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}
|
||||
"""
|
||||
|
@ -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']
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -37,6 +37,9 @@ urlpatterns = [
|
||||
path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))),
|
||||
path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
|
||||
|
||||
path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))),
|
||||
path('module-type-profiles/<int:pk>/', include(get_model_urls('dcim', 'moduletypeprofile'))),
|
||||
|
||||
path('module-types/', include(get_model_urls('dcim', 'moduletype', detail=False))),
|
||||
path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -1161,6 +1161,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
'module',
|
||||
'modulebay',
|
||||
'moduletype',
|
||||
'moduletypeprofile',
|
||||
'platform',
|
||||
'powerfeed',
|
||||
'poweroutlet',
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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')),
|
||||
),
|
||||
),
|
||||
|
@ -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 = '<br />'.join([
|
||||
f'{escape(k)}: {escape(v)}' for k, v in value.items()
|
||||
])
|
||||
return mark_safe(output)
|
||||
|
@ -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 @@
|
||||
<td>{{ object.device.device_type|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Module Type" %}</th>
|
||||
<td>{{ object.module_type|linkify:"full_name" }}</td>
|
||||
<th scope="row">{% trans "Module Bay" %}</th>
|
||||
<td>{% nested_tree object.module_bay %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Status" %}</th>
|
||||
@ -88,6 +88,25 @@
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module Type" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.module_type.manufacturer|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Model" %}</th>
|
||||
<td>{{ object.module_type|linkify }}</td>
|
||||
</tr>
|
||||
{% for k, v in object.module_type.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>{{ v|placeholder }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
|
@ -23,6 +23,10 @@
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module Type" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Profile" %}</th>
|
||||
<td>{{ object.profile|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||
<td>{{ object.manufacturer|linkify }}</td>
|
||||
@ -60,6 +64,27 @@
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Attributes" %}</h2>
|
||||
{% if not object.profile %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "No profile assigned" %}
|
||||
</div>
|
||||
{% elif object.attributes %}
|
||||
<table class="table table-hover attr-table">
|
||||
{% for k, v in object.attributes.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ k }}</th>
|
||||
<td>{{ v|placeholder }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "None" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/image_attachments.html' %}
|
||||
|
59
netbox/templates/dcim/moduletypeprofile.html
Normal file
59
netbox/templates/dcim/moduletypeprofile.html
Normal file
@ -0,0 +1,59 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ object.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Module Type Profile" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header d-flex justify-content-between">
|
||||
{% trans "Schema" %}
|
||||
{% copy_content 'profile_schema' %}
|
||||
</h2>
|
||||
<pre id="profile_schema">{{ object.schema|json }}</pre>
|
||||
</div>
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">
|
||||
{% trans "Module Types" %}
|
||||
{% if perms.dcim.add_moduletype %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'dcim:moduletype_add' %}?profile={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Module Type" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% htmx_table 'dcim:moduletype_list' profile_id=object.pk %}
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -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)
|
||||
|
||||
|
166
netbox/utilities/jsonschema.py
Normal file
166
netbox/utilities/jsonschema.py
Normal file
@ -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))
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user