mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -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/
|
# https://jinja.palletsprojects.com/changes/
|
||||||
Jinja2
|
Jinja2
|
||||||
|
|
||||||
|
# JSON schema validation
|
||||||
|
# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst
|
||||||
|
jsonschema
|
||||||
|
|
||||||
# Simple markup language for rendering HTML
|
# Simple markup language for rendering HTML
|
||||||
# https://python-markdown.github.io/changelog/
|
# https://python-markdown.github.io/changelog/
|
||||||
Markdown
|
Markdown
|
||||||
|
@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
|
|||||||
### Airflow
|
### Airflow
|
||||||
|
|
||||||
The direction in which air circulates through the device chassis for cooling.
|
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 rest_framework import serializers
|
||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.models import DeviceType, ModuleType
|
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
|
||||||
from netbox.api.fields import ChoiceField, RelatedObjectCountField
|
from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
|
||||||
from netbox.api.serializers import NetBoxModelSerializer
|
from netbox.api.serializers import NetBoxModelSerializer
|
||||||
from netbox.choices import *
|
from netbox.choices import *
|
||||||
from .manufacturers import ManufacturerSerializer
|
from .manufacturers import ManufacturerSerializer
|
||||||
@ -13,6 +13,7 @@ from .platforms import PlatformSerializer
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'DeviceTypeSerializer',
|
'DeviceTypeSerializer',
|
||||||
|
'ModuleTypeProfileSerializer',
|
||||||
'ModuleTypeSerializer',
|
'ModuleTypeSerializer',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
|
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):
|
class ModuleTypeSerializer(NetBoxModelSerializer):
|
||||||
|
profile = ModuleTypeProfileSerializer(
|
||||||
|
nested=True,
|
||||||
|
required=False,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
manufacturer = ManufacturerSerializer(
|
manufacturer = ManufacturerSerializer(
|
||||||
nested=True
|
nested=True
|
||||||
)
|
)
|
||||||
@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
|
|||||||
required=False,
|
required=False,
|
||||||
allow_null=True
|
allow_null=True
|
||||||
)
|
)
|
||||||
|
attributes = AttributesField(
|
||||||
|
source='attribute_data',
|
||||||
|
required=False,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow',
|
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
|
||||||
'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields',
|
'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
|
||||||
'created', 'last_updated',
|
'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('manufacturers', views.ManufacturerViewSet)
|
||||||
router.register('device-types', views.DeviceTypeViewSet)
|
router.register('device-types', views.DeviceTypeViewSet)
|
||||||
router.register('module-types', views.ModuleTypeViewSet)
|
router.register('module-types', views.ModuleTypeViewSet)
|
||||||
|
router.register('module-type-profiles', views.ModuleTypeProfileViewSet)
|
||||||
|
|
||||||
# Device type components
|
# Device type components
|
||||||
router.register('console-port-templates', views.ConsolePortTemplateViewSet)
|
router.register('console-port-templates', views.ConsolePortTemplateViewSet)
|
||||||
|
@ -269,6 +269,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet):
|
|||||||
filterset_class = filtersets.DeviceTypeFilterSet
|
filterset_class = filtersets.DeviceTypeFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleTypeProfileViewSet(NetBoxModelViewSet):
|
||||||
|
queryset = ModuleTypeProfile.objects.all()
|
||||||
|
serializer_class = serializers.ModuleTypeProfileSerializer
|
||||||
|
filterset_class = filtersets.ModuleTypeProfileFilterSet
|
||||||
|
|
||||||
|
|
||||||
class ModuleTypeViewSet(NetBoxModelViewSet):
|
class ModuleTypeViewSet(NetBoxModelViewSet):
|
||||||
queryset = ModuleType.objects.all()
|
queryset = ModuleType.objects.all()
|
||||||
serializer_class = serializers.ModuleTypeSerializer
|
serializer_class = serializers.ModuleTypeSerializer
|
||||||
|
@ -11,7 +11,7 @@ from ipam.filtersets import PrimaryIPFilterSet
|
|||||||
from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
|
from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
|
||||||
from netbox.choices import ColorChoices
|
from netbox.choices import ColorChoices
|
||||||
from netbox.filtersets import (
|
from netbox.filtersets import (
|
||||||
BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
|
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
|
||||||
OrganizationalModelFilterSet,
|
OrganizationalModelFilterSet,
|
||||||
)
|
)
|
||||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||||
@ -59,6 +59,7 @@ __all__ = (
|
|||||||
'ModuleBayTemplateFilterSet',
|
'ModuleBayTemplateFilterSet',
|
||||||
'ModuleFilterSet',
|
'ModuleFilterSet',
|
||||||
'ModuleTypeFilterSet',
|
'ModuleTypeFilterSet',
|
||||||
|
'ModuleTypeProfileFilterSet',
|
||||||
'PathEndpointFilterSet',
|
'PathEndpointFilterSet',
|
||||||
'PlatformFilterSet',
|
'PlatformFilterSet',
|
||||||
'PowerConnectionFilterSet',
|
'PowerConnectionFilterSet',
|
||||||
@ -674,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
|||||||
return queryset.exclude(inventoryitemtemplates__isnull=value)
|
return queryset.exclude(inventoryitemtemplates__isnull=value)
|
||||||
|
|
||||||
|
|
||||||
class ModuleTypeFilterSet(NetBoxModelFilterSet):
|
class ModuleTypeProfileFilterSet(NetBoxModelFilterSet):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ModuleTypeProfile
|
||||||
|
fields = ('id', 'name', 'description')
|
||||||
|
|
||||||
|
def search(self, queryset, name, value):
|
||||||
|
if not value.strip():
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(
|
||||||
|
Q(name__icontains=value) |
|
||||||
|
Q(description__icontains=value) |
|
||||||
|
Q(comments__icontains=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
|
||||||
|
profile_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
queryset=ModuleTypeProfile.objects.all(),
|
||||||
|
label=_('Profile (ID)'),
|
||||||
|
)
|
||||||
|
profile = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='profile__name',
|
||||||
|
queryset=ModuleTypeProfile.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
label=_('Profile (name)'),
|
||||||
|
)
|
||||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
label=_('Manufacturer (ID)'),
|
label=_('Manufacturer (ID)'),
|
||||||
|
@ -14,7 +14,9 @@ from netbox.forms import NetBoxModelBulkEditForm
|
|||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
|
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.rendering import FieldSet, InlineFields, TabbedGroups
|
||||||
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
|
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
|
||||||
from virtualization.models import Cluster
|
from virtualization.models import Cluster
|
||||||
@ -46,6 +48,7 @@ __all__ = (
|
|||||||
'ModuleBayBulkEditForm',
|
'ModuleBayBulkEditForm',
|
||||||
'ModuleBayTemplateBulkEditForm',
|
'ModuleBayTemplateBulkEditForm',
|
||||||
'ModuleTypeBulkEditForm',
|
'ModuleTypeBulkEditForm',
|
||||||
|
'ModuleTypeProfileBulkEditForm',
|
||||||
'PlatformBulkEditForm',
|
'PlatformBulkEditForm',
|
||||||
'PowerFeedBulkEditForm',
|
'PowerFeedBulkEditForm',
|
||||||
'PowerOutletBulkEditForm',
|
'PowerOutletBulkEditForm',
|
||||||
@ -574,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
|
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):
|
class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
profile = DynamicModelChoiceField(
|
||||||
|
label=_('Profile'),
|
||||||
|
queryset=ModuleTypeProfile.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
manufacturer = DynamicModelChoiceField(
|
manufacturer = DynamicModelChoiceField(
|
||||||
label=_('Manufacturer'),
|
label=_('Manufacturer'),
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
@ -609,14 +636,14 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
|
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')),
|
FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'airflow',
|
'airflow',
|
||||||
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
|
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
|
||||||
name=_('Chassis')
|
name=_('Chassis')
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')
|
nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments')
|
||||||
|
|
||||||
|
|
||||||
class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
|
class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
@ -39,6 +39,7 @@ __all__ = (
|
|||||||
'ModuleImportForm',
|
'ModuleImportForm',
|
||||||
'ModuleBayImportForm',
|
'ModuleBayImportForm',
|
||||||
'ModuleTypeImportForm',
|
'ModuleTypeImportForm',
|
||||||
|
'ModuleTypeProfileImportForm',
|
||||||
'PlatformImportForm',
|
'PlatformImportForm',
|
||||||
'PowerFeedImportForm',
|
'PowerFeedImportForm',
|
||||||
'PowerOutletImportForm',
|
'PowerOutletImportForm',
|
||||||
@ -427,7 +428,22 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleTypeProfileImportForm(NetBoxModelImportForm):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ModuleTypeProfile
|
||||||
|
fields = [
|
||||||
|
'name', 'description', 'schema', 'comments', 'tags',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ModuleTypeImportForm(NetBoxModelImportForm):
|
class ModuleTypeImportForm(NetBoxModelImportForm):
|
||||||
|
profile = forms.ModelChoiceField(
|
||||||
|
label=_('Profile'),
|
||||||
|
queryset=ModuleTypeProfile.objects.all(),
|
||||||
|
to_field_name='name',
|
||||||
|
required=False
|
||||||
|
)
|
||||||
manufacturer = forms.ModelChoiceField(
|
manufacturer = forms.ModelChoiceField(
|
||||||
label=_('Manufacturer'),
|
label=_('Manufacturer'),
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
|
@ -39,6 +39,7 @@ __all__ = (
|
|||||||
'ModuleFilterForm',
|
'ModuleFilterForm',
|
||||||
'ModuleBayFilterForm',
|
'ModuleBayFilterForm',
|
||||||
'ModuleTypeFilterForm',
|
'ModuleTypeFilterForm',
|
||||||
|
'ModuleTypeProfileFilterForm',
|
||||||
'PlatformFilterForm',
|
'PlatformFilterForm',
|
||||||
'PowerConnectionFilterForm',
|
'PowerConnectionFilterForm',
|
||||||
'PowerFeedFilterForm',
|
'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):
|
class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id', 'tag'),
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
|
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
|
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
|
||||||
'pass_through_ports', name=_('Components')
|
'pass_through_ports', name=_('Components')
|
||||||
@ -614,6 +623,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
|
|||||||
FieldSet('weight', 'weight_unit', name=_('Weight')),
|
FieldSet('weight', 'weight_unit', name=_('Weight')),
|
||||||
)
|
)
|
||||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||||
|
profile_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=ModuleTypeProfile.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Profile')
|
||||||
|
)
|
||||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.validators import EMPTY_VALUES
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from timezone_field import TimeZoneFormField
|
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.rendering import FieldSet, InlineFields, TabbedGroups
|
||||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
||||||
|
from utilities.jsonschema import JSONSchemaProperty
|
||||||
from virtualization.models import Cluster, VMInterface
|
from virtualization.models import Cluster, VMInterface
|
||||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||||
@ -48,6 +50,7 @@ __all__ = (
|
|||||||
'ModuleBayForm',
|
'ModuleBayForm',
|
||||||
'ModuleBayTemplateForm',
|
'ModuleBayTemplateForm',
|
||||||
'ModuleTypeForm',
|
'ModuleTypeForm',
|
||||||
|
'ModuleTypeProfileForm',
|
||||||
'PlatformForm',
|
'PlatformForm',
|
||||||
'PopulateDeviceBayForm',
|
'PopulateDeviceBayForm',
|
||||||
'PowerFeedForm',
|
'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):
|
class ModuleTypeForm(NetBoxModelForm):
|
||||||
|
profile = forms.ModelChoiceField(
|
||||||
|
queryset=ModuleTypeProfile.objects.all(),
|
||||||
|
label=_('Profile'),
|
||||||
|
required=False,
|
||||||
|
widget=HTMXSelect()
|
||||||
|
)
|
||||||
manufacturer = DynamicModelChoiceField(
|
manufacturer = DynamicModelChoiceField(
|
||||||
label=_('Manufacturer'),
|
label=_('Manufacturer'),
|
||||||
queryset=Manufacturer.objects.all()
|
queryset=Manufacturer.objects.all()
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
fieldsets = (
|
@property
|
||||||
FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
|
def fieldsets(self):
|
||||||
FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis'))
|
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:
|
class Meta:
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
|
'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
|
||||||
'comments', 'tags',
|
'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):
|
class DeviceRoleForm(NetBoxModelForm):
|
||||||
config_template = DynamicModelChoiceField(
|
config_template = DynamicModelChoiceField(
|
||||||
|
@ -68,6 +68,7 @@ __all__ = (
|
|||||||
'ModuleBayFilter',
|
'ModuleBayFilter',
|
||||||
'ModuleBayTemplateFilter',
|
'ModuleBayTemplateFilter',
|
||||||
'ModuleTypeFilter',
|
'ModuleTypeFilter',
|
||||||
|
'ModuleTypeProfileFilter',
|
||||||
'PlatformFilter',
|
'PlatformFilter',
|
||||||
'PowerFeedFilter',
|
'PowerFeedFilter',
|
||||||
'PowerOutletFilter',
|
'PowerOutletFilter',
|
||||||
@ -559,6 +560,11 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
|
|||||||
position: FilterLookup[str] | None = strawberry_django.filter_field()
|
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)
|
@strawberry_django.filter(models.ModuleType, lookups=True)
|
||||||
class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
|
class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
|
||||||
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
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: ModuleBayTemplateType = strawberry_django.field()
|
||||||
module_bay_template_list: List[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: ModuleTypeType = strawberry_django.field()
|
||||||
module_type_list: List[ModuleTypeType] = strawberry_django.field()
|
module_type_list: List[ModuleTypeType] = strawberry_django.field()
|
||||||
|
|
||||||
|
@ -61,6 +61,7 @@ __all__ = (
|
|||||||
'ModuleType',
|
'ModuleType',
|
||||||
'ModuleBayType',
|
'ModuleBayType',
|
||||||
'ModuleBayTemplateType',
|
'ModuleBayTemplateType',
|
||||||
|
'ModuleTypeProfileType',
|
||||||
'ModuleTypeType',
|
'ModuleTypeType',
|
||||||
'PlatformType',
|
'PlatformType',
|
||||||
'PowerFeedType',
|
'PowerFeedType',
|
||||||
@ -593,6 +594,16 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
|
|||||||
pass
|
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(
|
@strawberry_django.type(
|
||||||
models.ModuleType,
|
models.ModuleType,
|
||||||
fields='__all__',
|
fields='__all__',
|
||||||
@ -600,6 +611,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
|
|||||||
pagination=True
|
pagination=True
|
||||||
)
|
)
|
||||||
class ModuleTypeType(NetBoxObjectType):
|
class ModuleTypeType(NetBoxObjectType):
|
||||||
|
profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
|
||||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||||
|
|
||||||
frontporttemplates: List[Annotated["FrontPortTemplateType", 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_component_templates import *
|
||||||
from .device_components import *
|
from .device_components import *
|
||||||
from .devices import *
|
from .devices import *
|
||||||
|
from .modules import *
|
||||||
from .power import *
|
from .power import *
|
||||||
from .racks import *
|
from .racks import *
|
||||||
from .sites import *
|
from .sites import *
|
||||||
|
@ -19,6 +19,7 @@ from core.models import ObjectType
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.fields import MACAddressField
|
from dcim.fields import MACAddressField
|
||||||
|
from dcim.utils import update_interface_bridges
|
||||||
from extras.models import ConfigContextModel, CustomField
|
from extras.models import ConfigContextModel, CustomField
|
||||||
from extras.querysets import ConfigContextModelQuerySet
|
from extras.querysets import ConfigContextModelQuerySet
|
||||||
from netbox.choices import ColorChoices
|
from netbox.choices import ColorChoices
|
||||||
@ -30,6 +31,7 @@ from utilities.fields import ColorField, CounterCacheField
|
|||||||
from utilities.tracking import TrackingModelMixin
|
from utilities.tracking import TrackingModelMixin
|
||||||
from .device_components import *
|
from .device_components import *
|
||||||
from .mixins import RenderConfigMixin
|
from .mixins import RenderConfigMixin
|
||||||
|
from .modules import Module
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -38,8 +40,6 @@ __all__ = (
|
|||||||
'DeviceType',
|
'DeviceType',
|
||||||
'MACAddress',
|
'MACAddress',
|
||||||
'Manufacturer',
|
'Manufacturer',
|
||||||
'Module',
|
|
||||||
'ModuleType',
|
|
||||||
'Platform',
|
'Platform',
|
||||||
'VirtualChassis',
|
'VirtualChassis',
|
||||||
'VirtualDeviceContext',
|
'VirtualDeviceContext',
|
||||||
@ -367,103 +367,6 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
|
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
|
# Devices
|
||||||
#
|
#
|
||||||
@ -526,23 +429,6 @@ class Platform(OrganizationalModel):
|
|||||||
verbose_name_plural = _('platforms')
|
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(
|
class Device(
|
||||||
ContactsMixin,
|
ContactsMixin,
|
||||||
ImageAttachmentsMixin,
|
ImageAttachmentsMixin,
|
||||||
@ -1155,170 +1041,6 @@ class Device(
|
|||||||
return round(total_weight / 1000, 2)
|
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
|
# 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')
|
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
|
@register_search
|
||||||
class ModuleTypeIndex(SearchIndex):
|
class ModuleTypeIndex(SearchIndex):
|
||||||
model = models.ModuleType
|
model = models.ModuleType
|
||||||
|
@ -1,25 +1,64 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
import django_tables2 as tables
|
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 netbox.tables import NetBoxTable, columns
|
||||||
from .template_code import WEIGHT
|
from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ModuleTable',
|
'ModuleTable',
|
||||||
|
'ModuleTypeProfileTable',
|
||||||
'ModuleTypeTable',
|
'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):
|
class ModuleTypeTable(NetBoxTable):
|
||||||
model = tables.Column(
|
profile = tables.Column(
|
||||||
linkify=True,
|
verbose_name=_('Profile'),
|
||||||
verbose_name=_('Module Type')
|
linkify=True
|
||||||
)
|
)
|
||||||
manufacturer = tables.Column(
|
manufacturer = tables.Column(
|
||||||
verbose_name=_('Manufacturer'),
|
verbose_name=_('Manufacturer'),
|
||||||
linkify=True
|
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(
|
instance_count = columns.LinkedCountColumn(
|
||||||
viewname='dcim:module_list',
|
viewname='dcim:module_list',
|
||||||
url_params={'module_type_id': 'pk'},
|
url_params={'module_type_id': 'pk'},
|
||||||
@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable):
|
|||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:moduletype_list'
|
url_name='dcim:moduletype_list'
|
||||||
)
|
)
|
||||||
weight = columns.TemplateColumn(
|
|
||||||
verbose_name=_('Weight'),
|
|
||||||
template_code=WEIGHT,
|
|
||||||
order_by=('_abs_weight', 'weight_unit')
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags',
|
'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
|
||||||
'created', 'last_updated',
|
'attributes', 'comments', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'model', 'manufacturer', 'part_number',
|
'pk', 'model', 'profile', 'manufacturer', 'part_number',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -568,3 +568,7 @@ MODULEBAY_BUTTONS = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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):
|
class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = ModuleType
|
model = ModuleType
|
||||||
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
|
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url']
|
||||||
bulk_update_data = {
|
bulk_update_data = {
|
||||||
'part_number': 'ABC123',
|
'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):
|
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = ConsolePortTemplate
|
model = ConsolePortTemplate
|
||||||
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
brief_fields = ['description', 'display', 'id', 'name', 'url']
|
||||||
|
@ -1486,6 +1486,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = ModuleType.objects.all()
|
queryset = ModuleType.objects.all()
|
||||||
filterset = ModuleTypeFilterSet
|
filterset = ModuleTypeFilterSet
|
||||||
|
ignore_fields = ['attribute_data']
|
||||||
|
|
||||||
|
PROFILE_SCHEMA = {
|
||||||
|
"properties": {
|
||||||
|
"string": {"type": "string"},
|
||||||
|
"integer": {"type": "integer"},
|
||||||
|
"number": {"type": "number"},
|
||||||
|
"boolean": {"type": "boolean"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@ -1496,6 +1506,21 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
|
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
|
||||||
)
|
)
|
||||||
Manufacturer.objects.bulk_create(manufacturers)
|
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 = (
|
module_types = (
|
||||||
ModuleType(
|
ModuleType(
|
||||||
@ -1505,7 +1530,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
weight=10,
|
weight=10,
|
||||||
weight_unit=WeightUnitChoices.UNIT_POUND,
|
weight_unit=WeightUnitChoices.UNIT_POUND,
|
||||||
description='foobar1',
|
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(
|
ModuleType(
|
||||||
manufacturer=manufacturers[1],
|
manufacturer=manufacturers[1],
|
||||||
@ -1514,7 +1546,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
weight=20,
|
weight=20,
|
||||||
weight_unit=WeightUnitChoices.UNIT_POUND,
|
weight_unit=WeightUnitChoices.UNIT_POUND,
|
||||||
description='foobar2',
|
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(
|
ModuleType(
|
||||||
manufacturer=manufacturers[2],
|
manufacturer=manufacturers[2],
|
||||||
@ -1522,7 +1561,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
part_number='Part Number 3',
|
part_number='Part Number 3',
|
||||||
weight=30,
|
weight=30,
|
||||||
weight_unit=WeightUnitChoices.UNIT_KILOGRAM,
|
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)
|
ModuleType.objects.bulk_create(module_types)
|
||||||
@ -1641,6 +1687,82 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'airflow': RackAirflowChoices.FRONT_TO_REAR}
|
params = {'airflow': RackAirflowChoices.FRONT_TO_REAR}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
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):
|
class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
|
||||||
queryset = ConsolePortTemplate.objects.all()
|
queryset = ConsolePortTemplate.objects.all()
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
@ -1305,6 +1306,79 @@ front-ports:
|
|||||||
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
|
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
|
# DeviceType components
|
||||||
#
|
#
|
||||||
|
@ -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', detail=False))),
|
||||||
path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
|
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/', include(get_model_urls('dcim', 'moduletype', detail=False))),
|
||||||
path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),
|
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.contrib.contenttypes.models import ContentType
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
@ -56,3 +57,22 @@ def rebuild_paths(terminations):
|
|||||||
for cp in cable_paths:
|
for cp in cable_paths:
|
||||||
cp.delete()
|
cp.delete()
|
||||||
create_cablepath(cp.origins)
|
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
|
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
|
# Module types
|
||||||
#
|
#
|
||||||
|
@ -1161,6 +1161,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
'module',
|
'module',
|
||||||
'modulebay',
|
'modulebay',
|
||||||
'moduletype',
|
'moduletype',
|
||||||
|
'moduletypeprofile',
|
||||||
'platform',
|
'platform',
|
||||||
'powerfeed',
|
'powerfeed',
|
||||||
'poweroutlet',
|
'poweroutlet',
|
||||||
|
@ -9,6 +9,7 @@ from rest_framework.exceptions import ValidationError
|
|||||||
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
|
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'AttributesField',
|
||||||
'ChoiceField',
|
'ChoiceField',
|
||||||
'ContentTypeField',
|
'ContentTypeField',
|
||||||
'IPNetworkSerializer',
|
'IPNetworkSerializer',
|
||||||
@ -172,3 +173,19 @@ class IntegerRangeSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
return instance.lower, instance.upper - 1
|
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
|
import django_filters
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@ -20,6 +22,7 @@ from utilities.forms.fields import MACAddressField
|
|||||||
from utilities import filters
|
from utilities import filters
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
|
'AttributeFiltersMixin',
|
||||||
'BaseFilterSet',
|
'BaseFilterSet',
|
||||||
'ChangeLoggedModelFilterSet',
|
'ChangeLoggedModelFilterSet',
|
||||||
'NetBoxModelFilterSet',
|
'NetBoxModelFilterSet',
|
||||||
@ -345,3 +348,32 @@ class NestedGroupModelFilterSet(NetBoxModelFilterSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return queryset
|
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=(
|
items=(
|
||||||
get_model_item('dcim', 'devicetype', _('Device Types')),
|
get_model_item('dcim', 'devicetype', _('Device Types')),
|
||||||
get_model_item('dcim', 'moduletype', _('Module Types')),
|
get_model_item('dcim', 'moduletype', _('Module Types')),
|
||||||
|
get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')),
|
||||||
get_model_item('dcim', 'manufacturer', _('Manufacturers')),
|
get_model_item('dcim', 'manufacturer', _('Manufacturers')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -35,6 +35,7 @@ __all__ = (
|
|||||||
'ContentTypesColumn',
|
'ContentTypesColumn',
|
||||||
'CustomFieldColumn',
|
'CustomFieldColumn',
|
||||||
'CustomLinkColumn',
|
'CustomLinkColumn',
|
||||||
|
'DictColumn',
|
||||||
'DistanceColumn',
|
'DistanceColumn',
|
||||||
'DurationColumn',
|
'DurationColumn',
|
||||||
'LinkedCountColumn',
|
'LinkedCountColumn',
|
||||||
@ -707,3 +708,14 @@ class DistanceColumn(TemplateColumn):
|
|||||||
|
|
||||||
def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
|
def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
|
||||||
super().__init__(template_code=template_code, order_by=order_by, **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' %}
|
{% extends 'generic/object.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
{% load tz %}
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load mptt %}
|
||||||
|
|
||||||
{% block breadcrumbs %}
|
{% block breadcrumbs %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
@ -62,8 +62,8 @@
|
|||||||
<td>{{ object.device.device_type|linkify }}</td>
|
<td>{{ object.device.device_type|linkify }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Module Type" %}</th>
|
<th scope="row">{% trans "Module Bay" %}</th>
|
||||||
<td>{{ object.module_type|linkify:"full_name" }}</td>
|
<td>{% nested_tree object.module_bay %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Status" %}</th>
|
<th scope="row">{% trans "Status" %}</th>
|
||||||
@ -88,6 +88,25 @@
|
|||||||
{% plugin_left_page object %}
|
{% plugin_left_page object %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6">
|
<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/related_objects.html' %}
|
||||||
{% include 'inc/panels/custom_fields.html' %}
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
{% plugin_right_page object %}
|
{% plugin_right_page object %}
|
||||||
|
@ -23,6 +23,10 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-header">{% trans "Module Type" %}</h2>
|
<h2 class="card-header">{% trans "Module Type" %}</h2>
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Profile" %}</th>
|
||||||
|
<td>{{ object.profile|linkify|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Manufacturer" %}</th>
|
<th scope="row">{% trans "Manufacturer" %}</th>
|
||||||
<td>{{ object.manufacturer|linkify }}</td>
|
<td>{{ object.manufacturer|linkify }}</td>
|
||||||
@ -60,6 +64,27 @@
|
|||||||
{% plugin_left_page object %}
|
{% plugin_left_page object %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col col-md-6">
|
<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/related_objects.html' %}
|
||||||
{% include 'inc/panels/custom_fields.html' %}
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
{% include 'inc/panels/image_attachments.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]
|
field = form.fields[field_name]
|
||||||
|
|
||||||
if form.is_bound and (data := form.data.get(field_name)):
|
if form.is_bound and field_name in form.data:
|
||||||
if hasattr(field, 'valid_value') and field.valid_value(data):
|
if (value := form.data[field_name]) is None:
|
||||||
return data
|
return
|
||||||
|
if hasattr(field, 'valid_value') and field.valid_value(value):
|
||||||
|
return value
|
||||||
|
|
||||||
return form.get_initial_for_field(field, field_name)
|
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 import forms
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from dcim.models import Site
|
||||||
from netbox.choices import ImportFormatChoices
|
from netbox.choices import ImportFormatChoices
|
||||||
from utilities.forms.bulk_import import BulkImportForm
|
from utilities.forms.bulk_import import BulkImportForm
|
||||||
from utilities.forms.forms import BulkRenameForm
|
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):
|
class ExpandIPAddress(TestCase):
|
||||||
@ -387,3 +388,63 @@ class BulkRenameFormTest(TestCase):
|
|||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
self.assertEqual(form.cleaned_data["find"], " hello ")
|
self.assertEqual(form.cleaned_data["find"], " hello ")
|
||||||
self.assertEqual(form.cleaned_data["replace"], " world ")
|
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
|
import re
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@ -10,6 +11,7 @@ __all__ = (
|
|||||||
'ColorValidator',
|
'ColorValidator',
|
||||||
'EnhancedURLValidator',
|
'EnhancedURLValidator',
|
||||||
'ExclusionValidator',
|
'ExclusionValidator',
|
||||||
|
'MultipleOfValidator',
|
||||||
'validate_regex',
|
'validate_regex',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -54,6 +56,22 @@ class ExclusionValidator(BaseValidator):
|
|||||||
return a in b
|
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):
|
def validate_regex(value):
|
||||||
"""
|
"""
|
||||||
Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex
|
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
|
feedparser==6.0.11
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
Jinja2==3.1.5
|
Jinja2==3.1.5
|
||||||
|
jsonschema==4.23.0
|
||||||
Markdown==3.7
|
Markdown==3.7
|
||||||
mkdocs-material==9.6.7
|
mkdocs-material==9.6.7
|
||||||
mkdocstrings[python]==0.28.2
|
mkdocstrings[python]==0.28.2
|
||||||
|
Loading…
Reference in New Issue
Block a user