Closes #19002: Module type profiles (#19014)

* Move Module & ModuleType models to a separate file

* Add ModuleTypeProfile & related fields

* Initial work on JSON schema validation

* Add attributes property on ModuleType

* Introduce MultipleOfValidator

* Introduce JSONSchemaProperty

* Enable dynamic form field rendering

* Misc cleanup

* Fix migration conflict

* Ensure deterministic ordering of attriubte fields

* Support choices & default values

* Include module type attributes on module view

* Enable modifying individual attributes via REST API

* Enable filtering by attribute values

* Add documentation & tests

* Schema should be optional

* Include attributes column for profiles

* Profile is nullable

* Include some initial profiles to be installed via migration

* Fix migrations conflict

* Fix filterset test

* Misc cleanup

* Fixes #19023: get_field_value() should respect null values in bound forms (#19024)

* Skip filters which do not specify a JSON-serializable value

* Fix handling of array item types

* Fix initial data in schema field during bulk edit

* Implement sanity checking for JSON schema definitions

* Fall back to filtering by string value
This commit is contained in:
Jeremy Stretch 2025-04-01 13:05:06 -04:00 committed by GitHub
parent 864db469ba
commit 8d7889e2c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1732 additions and 321 deletions

View File

@ -82,6 +82,10 @@ gunicorn
# https://jinja.palletsprojects.com/changes/
Jinja2
# JSON schema validation
# https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst
jsonschema
# Simple markup language for rendering HTML
# https://python-markdown.github.io/changelog/
Markdown

View File

@ -43,3 +43,11 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
### Airflow
The direction in which air circulates through the device chassis for cooling.
### Profile
The assigned [profile](./moduletypeprofile.md) for the type of module. Profiles can be used to classify module types by function (e.g. power supply, hard disk, etc.), and they support the addition of user-configurable attributes on module types. The assignment of a module type to a profile is optional.
### Attributes
Depending on the module type's assigned [profile](./moduletypeprofile.md) (if any), one or more user-defined attributes may be available to configure.

View 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).

View File

@ -4,8 +4,8 @@ from django.utils.translation import gettext as _
from rest_framework import serializers
from dcim.choices import *
from dcim.models import DeviceType, ModuleType
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from dcim.models import DeviceType, ModuleType, ModuleTypeProfile
from netbox.api.fields import AttributesField, ChoiceField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from netbox.choices import *
from .manufacturers import ManufacturerSerializer
@ -13,6 +13,7 @@ from .platforms import PlatformSerializer
__all__ = (
'DeviceTypeSerializer',
'ModuleTypeProfileSerializer',
'ModuleTypeSerializer',
)
@ -62,7 +63,23 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
class ModuleTypeProfileSerializer(NetBoxModelSerializer):
class Meta:
model = ModuleTypeProfile
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'schema', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class ModuleTypeSerializer(NetBoxModelSerializer):
profile = ModuleTypeProfileSerializer(
nested=True,
required=False,
allow_null=True
)
manufacturer = ManufacturerSerializer(
nested=True
)
@ -78,12 +95,17 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
required=False,
allow_null=True
)
attributes = AttributesField(
source='attribute_data',
required=False,
allow_null=True
)
class Meta:
model = ModuleType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'profile', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'attributes', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
brief_fields = ('id', 'url', 'display', 'profile', 'manufacturer', 'model', 'description')

View File

@ -21,6 +21,7 @@ router.register('rack-reservations', views.RackReservationViewSet)
router.register('manufacturers', views.ManufacturerViewSet)
router.register('device-types', views.DeviceTypeViewSet)
router.register('module-types', views.ModuleTypeViewSet)
router.register('module-type-profiles', views.ModuleTypeProfileViewSet)
# Device type components
router.register('console-port-templates', views.ConsolePortTemplateViewSet)

View File

@ -269,6 +269,12 @@ class DeviceTypeViewSet(NetBoxModelViewSet):
filterset_class = filtersets.DeviceTypeFilterSet
class ModuleTypeProfileViewSet(NetBoxModelViewSet):
queryset = ModuleTypeProfile.objects.all()
serializer_class = serializers.ModuleTypeProfileSerializer
filterset_class = filtersets.ModuleTypeProfileFilterSet
class ModuleTypeViewSet(NetBoxModelViewSet):
queryset = ModuleType.objects.all()
serializer_class = serializers.ModuleTypeSerializer

View File

@ -11,7 +11,7 @@ from ipam.filtersets import PrimaryIPFilterSet
from ipam.models import ASN, IPAddress, VLANTranslationPolicy, VRF
from netbox.choices import ColorChoices
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
AttributeFiltersMixin, BaseFilterSet, ChangeLoggedModelFilterSet, NestedGroupModelFilterSet, NetBoxModelFilterSet,
OrganizationalModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
@ -59,6 +59,7 @@ __all__ = (
'ModuleBayTemplateFilterSet',
'ModuleFilterSet',
'ModuleTypeFilterSet',
'ModuleTypeProfileFilterSet',
'PathEndpointFilterSet',
'PlatformFilterSet',
'PowerConnectionFilterSet',
@ -674,7 +675,33 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
return queryset.exclude(inventoryitemtemplates__isnull=value)
class ModuleTypeFilterSet(NetBoxModelFilterSet):
class ModuleTypeProfileFilterSet(NetBoxModelFilterSet):
class Meta:
model = ModuleTypeProfile
fields = ('id', 'name', 'description')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
class ModuleTypeFilterSet(AttributeFiltersMixin, NetBoxModelFilterSet):
profile_id = django_filters.ModelMultipleChoiceFilter(
queryset=ModuleTypeProfile.objects.all(),
label=_('Profile (ID)'),
)
profile = django_filters.ModelMultipleChoiceFilter(
field_name='profile__name',
queryset=ModuleTypeProfile.objects.all(),
to_field_name='name',
label=_('Profile (name)'),
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
label=_('Manufacturer (ID)'),

View File

@ -14,7 +14,9 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from users.models import User
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.fields import (
ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from virtualization.models import Cluster
@ -46,6 +48,7 @@ __all__ = (
'ModuleBayBulkEditForm',
'ModuleBayTemplateBulkEditForm',
'ModuleTypeBulkEditForm',
'ModuleTypeProfileBulkEditForm',
'PlatformBulkEditForm',
'PowerFeedBulkEditForm',
'PowerOutletBulkEditForm',
@ -574,7 +577,31 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
class ModuleTypeProfileBulkEditForm(NetBoxModelBulkEditForm):
schema = JSONField(
label=_('Schema'),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField()
model = ModuleTypeProfile
fieldsets = (
FieldSet('name', 'description', 'schema', name=_('Profile')),
)
nullable_fields = ('description', 'comments')
class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
profile = DynamicModelChoiceField(
label=_('Profile'),
queryset=ModuleTypeProfile.objects.all(),
required=False
)
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
@ -609,14 +636,14 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
model = ModuleType
fieldsets = (
FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')),
FieldSet('profile', 'manufacturer', 'part_number', 'description', name=_('Module Type')),
FieldSet(
'airflow',
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
name=_('Chassis')
),
)
nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')
nullable_fields = ('part_number', 'weight', 'weight_unit', 'profile', 'description', 'comments')
class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -39,6 +39,7 @@ __all__ = (
'ModuleImportForm',
'ModuleBayImportForm',
'ModuleTypeImportForm',
'ModuleTypeProfileImportForm',
'PlatformImportForm',
'PowerFeedImportForm',
'PowerOutletImportForm',
@ -427,7 +428,22 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
]
class ModuleTypeProfileImportForm(NetBoxModelImportForm):
class Meta:
model = ModuleTypeProfile
fields = [
'name', 'description', 'schema', 'comments', 'tags',
]
class ModuleTypeImportForm(NetBoxModelImportForm):
profile = forms.ModelChoiceField(
label=_('Profile'),
queryset=ModuleTypeProfile.objects.all(),
to_field_name='name',
required=False
)
manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),

View File

@ -39,6 +39,7 @@ __all__ = (
'ModuleFilterForm',
'ModuleBayFilterForm',
'ModuleTypeFilterForm',
'ModuleTypeProfileFilterForm',
'PlatformFilterForm',
'PowerConnectionFilterForm',
'PowerFeedFilterForm',
@ -602,11 +603,19 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
)
class ModuleTypeProfileFilterForm(NetBoxModelFilterSetForm):
model = ModuleTypeProfile
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
)
selector_fields = ('filter_id', 'q')
class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
model = ModuleType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
FieldSet('profile_id', 'manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', name=_('Components')
@ -614,6 +623,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
FieldSet('weight', 'weight_unit', name=_('Weight')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
profile_id = DynamicModelMultipleChoiceField(
queryset=ModuleTypeProfile.objects.all(),
required=False,
label=_('Profile')
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,

View File

@ -1,5 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.validators import EMPTY_VALUES
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField
@ -18,6 +19,7 @@ from utilities.forms.fields import (
)
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
from utilities.jsonschema import JSONSchemaProperty
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm, ModuleCommonForm
@ -48,6 +50,7 @@ __all__ = (
'ModuleBayForm',
'ModuleBayTemplateForm',
'ModuleTypeForm',
'ModuleTypeProfileForm',
'PlatformForm',
'PopulateDeviceBayForm',
'PowerFeedForm',
@ -404,25 +407,104 @@ class DeviceTypeForm(NetBoxModelForm):
}
class ModuleTypeProfileForm(NetBoxModelForm):
schema = JSONField(
label=_('Schema'),
required=False,
help_text=_("Enter a valid JSON schema to define supported attributes.")
)
comments = CommentField()
fieldsets = (
FieldSet('name', 'description', 'schema', 'tags', name=_('Profile')),
)
class Meta:
model = ModuleTypeProfile
fields = [
'name', 'description', 'schema', 'comments', 'tags',
]
class ModuleTypeForm(NetBoxModelForm):
profile = forms.ModelChoiceField(
queryset=ModuleTypeProfile.objects.all(),
label=_('Profile'),
required=False,
widget=HTMXSelect()
)
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all()
)
comments = CommentField()
fieldsets = (
FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis'))
)
@property
def fieldsets(self):
return [
FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
FieldSet('airflow', 'weight', 'weight_unit', name=_('Hardware')),
FieldSet('profile', *self.attr_fields, name=_('Profile & Attributes'))
]
class Meta:
model = ModuleType
fields = [
'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit',
'comments', 'tags',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Track profile-specific attribute fields
self.attr_fields = []
# Retrieve assigned ModuleTypeProfile, if any
if not (profile_id := get_field_value(self, 'profile')):
return
if not (profile := ModuleTypeProfile.objects.filter(pk=profile_id).first()):
return
# Extend form with fields for profile attributes
for attr, form_field in self._get_attr_form_fields(profile).items():
field_name = f'attr_{attr}'
self.attr_fields.append(field_name)
self.fields[field_name] = form_field
if self.instance.attribute_data:
self.fields[field_name].initial = self.instance.attribute_data.get(attr)
@staticmethod
def _get_attr_form_fields(profile):
"""
Return a dictionary mapping of attribute names to form fields, suitable for extending
the form per the selected ModuleTypeProfile.
"""
if not profile.schema:
return {}
properties = profile.schema.get('properties', {})
required_fields = profile.schema.get('required', [])
attr_fields = {}
for name, options in properties.items():
prop = JSONSchemaProperty(**options)
attr_fields[name] = prop.to_form_field(name, required=name in required_fields)
return dict(sorted(attr_fields.items()))
def _post_clean(self):
# Compile attribute data from the individual form fields
if self.cleaned_data.get('profile'):
self.instance.attribute_data = {
name[5:]: self.cleaned_data[name] # Remove the attr_ prefix
for name in self.attr_fields
if self.cleaned_data.get(name) not in EMPTY_VALUES
}
return super()._post_clean()
class DeviceRoleForm(NetBoxModelForm):
config_template = DynamicModelChoiceField(

View File

@ -68,6 +68,7 @@ __all__ = (
'ModuleBayFilter',
'ModuleBayTemplateFilter',
'ModuleTypeFilter',
'ModuleTypeProfileFilter',
'PlatformFilter',
'PowerFeedFilter',
'PowerOutletFilter',
@ -559,6 +560,11 @@ class ModuleBayTemplateFilter(ModularComponentTemplateFilterMixin):
position: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleTypeProfile, lookups=True)
class ModuleTypeProfileFilter(PrimaryModelFilterMixin):
name: FilterLookup[str] | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ModuleType, lookups=True)
class ModuleTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilterMixin, WeightFilterMixin):
manufacturer: Annotated['ManufacturerFilter', strawberry.lazy('dcim.graphql.filters')] | None = (

View File

@ -77,6 +77,9 @@ class DCIMQuery:
module_bay_template: ModuleBayTemplateType = strawberry_django.field()
module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field()
module_type_profile: ModuleTypeProfileType = strawberry_django.field()
module_type_profile_list: List[ModuleTypeProfileType] = strawberry_django.field()
module_type: ModuleTypeType = strawberry_django.field()
module_type_list: List[ModuleTypeType] = strawberry_django.field()

View File

@ -61,6 +61,7 @@ __all__ = (
'ModuleType',
'ModuleBayType',
'ModuleBayTemplateType',
'ModuleTypeProfileType',
'ModuleTypeType',
'PlatformType',
'PowerFeedType',
@ -593,6 +594,16 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
pass
@strawberry_django.type(
models.ModuleTypeProfile,
fields='__all__',
filters=ModuleTypeProfileFilter,
pagination=True
)
class ModuleTypeProfileType(NetBoxObjectType):
module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
models.ModuleType,
fields='__all__',
@ -600,6 +611,7 @@ class ModuleBayTemplateType(ModularComponentTemplateType):
pagination=True
)
class ModuleTypeType(NetBoxObjectType):
profile: Annotated["ModuleTypeProfileType", strawberry.lazy('dcim.graphql.types')] | None
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]

View 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')},
),
]

View 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),
]

View File

@ -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"
}
}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "Fan",
"schema": {
"properties": {
"rpm": {
"type": "integer",
"title": "RPM",
"description": "Fan speed (RPM)"
}
}
}
}

View File

@ -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"
]
}
}

View File

@ -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"
]
}
}

View File

@ -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"
]
}
}

View File

@ -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"
]
}
}

View File

@ -2,6 +2,7 @@ from .cables import *
from .device_component_templates import *
from .device_components import *
from .devices import *
from .modules import *
from .power import *
from .racks import *
from .sites import *

View File

@ -19,6 +19,7 @@ from core.models import ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField
from dcim.utils import update_interface_bridges
from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
@ -30,6 +31,7 @@ from utilities.fields import ColorField, CounterCacheField
from utilities.tracking import TrackingModelMixin
from .device_components import *
from .mixins import RenderConfigMixin
from .modules import Module
__all__ = (
@ -38,8 +40,6 @@ __all__ = (
'DeviceType',
'MACAddress',
'Manufacturer',
'Module',
'ModuleType',
'Platform',
'VirtualChassis',
'VirtualDeviceContext',
@ -367,103 +367,6 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
"""
A ModuleType represents a hardware element that can be installed within a device and which houses additional
components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It
cannot, however house device bays or module bays.
"""
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='module_types'
)
model = models.CharField(
verbose_name=_('model'),
max_length=100
)
part_number = models.CharField(
verbose_name=_('part number'),
max_length=50,
blank=True,
help_text=_('Discrete part number (optional)')
)
airflow = models.CharField(
verbose_name=_('airflow'),
max_length=50,
choices=ModuleAirflowChoices,
blank=True,
null=True
)
clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow')
prerequisite_models = (
'dcim.Manufacturer',
)
class Meta:
ordering = ('manufacturer', 'model')
constraints = (
models.UniqueConstraint(
fields=('manufacturer', 'model'),
name='%(app_label)s_%(class)s_unique_manufacturer_model'
),
)
verbose_name = _('module type')
verbose_name_plural = _('module types')
def __str__(self):
return self.model
@property
def full_name(self):
return f"{self.manufacturer} {self.model}"
def to_yaml(self):
data = {
'manufacturer': self.manufacturer.name,
'model': self.model,
'part_number': self.part_number,
'description': self.description,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
'comments': self.comments,
}
# Component templates
if self.consoleporttemplates.exists():
data['console-ports'] = [
c.to_yaml() for c in self.consoleporttemplates.all()
]
if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [
c.to_yaml() for c in self.consoleserverporttemplates.all()
]
if self.powerporttemplates.exists():
data['power-ports'] = [
c.to_yaml() for c in self.powerporttemplates.all()
]
if self.poweroutlettemplates.exists():
data['power-outlets'] = [
c.to_yaml() for c in self.poweroutlettemplates.all()
]
if self.interfacetemplates.exists():
data['interfaces'] = [
c.to_yaml() for c in self.interfacetemplates.all()
]
if self.frontporttemplates.exists():
data['front-ports'] = [
c.to_yaml() for c in self.frontporttemplates.all()
]
if self.rearporttemplates.exists():
data['rear-ports'] = [
c.to_yaml() for c in self.rearporttemplates.all()
]
return yaml.dump(dict(data), sort_keys=False)
#
# Devices
#
@ -526,23 +429,6 @@ class Platform(OrganizationalModel):
verbose_name_plural = _('platforms')
def update_interface_bridges(device, interface_templates, module=None):
"""
Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
and applies it to the actual interfaces.
"""
for interface_template in interface_templates.exclude(bridge=None):
interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
if interface_template.bridge:
interface.bridge = Interface.objects.get(
device=device,
name=interface_template.bridge.resolve_name(module=module)
)
interface.full_clean()
interface.save()
class Device(
ContactsMixin,
ImageAttachmentsMixin,
@ -1155,170 +1041,6 @@ class Device(
return round(total_weight / 1000, 2)
class Module(PrimaryModel, ConfigContextModel):
"""
A Module represents a field-installable component within a Device which may itself hold multiple device components
(for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='modules'
)
module_bay = models.OneToOneField(
to='dcim.ModuleBay',
on_delete=models.CASCADE,
related_name='installed_module'
)
module_type = models.ForeignKey(
to='dcim.ModuleType',
on_delete=models.PROTECT,
related_name='instances'
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=ModuleStatusChoices,
default=ModuleStatusChoices.STATUS_ACTIVE
)
serial = models.CharField(
max_length=50,
blank=True,
verbose_name=_('serial number')
)
asset_tag = models.CharField(
max_length=50,
blank=True,
null=True,
unique=True,
verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this device')
)
clone_fields = ('device', 'module_type', 'status')
class Meta:
ordering = ('module_bay',)
verbose_name = _('module')
verbose_name_plural = _('modules')
def __str__(self):
return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
def get_status_color(self):
return ModuleStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
raise ValidationError(
_("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
device=self.device
)
)
# Check for recursion
module = self
module_bays = []
modules = []
while module:
if module.pk in modules or module.module_bay.pk in module_bays:
raise ValidationError(_("A module bay cannot belong to a module installed within it."))
modules.append(module.pk)
module_bays.append(module.module_bay.pk)
module = module.module_bay.module if module.module_bay else None
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
adopt_components = getattr(self, '_adopt_components', False)
disable_replication = getattr(self, '_disable_replication', False)
# We skip adding components if the module is being edited or
# both replication and component adoption is disabled
if not is_new or (disable_replication and not adopt_components):
return
# Iterate all component types
for templates, component_attribute, component_model in [
("consoleporttemplates", "consoleports", ConsolePort),
("consoleserverporttemplates", "consoleserverports", ConsoleServerPort),
("interfacetemplates", "interfaces", Interface),
("powerporttemplates", "powerports", PowerPort),
("poweroutlettemplates", "poweroutlets", PowerOutlet),
("rearporttemplates", "rearports", RearPort),
("frontporttemplates", "frontports", FrontPort),
("modulebaytemplates", "modulebays", ModuleBay),
]:
create_instances = []
update_instances = []
# Prefetch installed components
installed_components = {
component.name: component
for component in getattr(self.device, component_attribute).filter(module__isnull=True)
}
# Get the template for the module type.
for template in getattr(self.module_type, templates).all():
template_instance = template.instantiate(device=self.device, module=self)
if adopt_components:
existing_item = installed_components.get(template_instance.name)
# Check if there's a component with the same name already
if existing_item:
# Assign it to the module
existing_item.module = self
update_instances.append(existing_item)
continue
# Only create new components if replication is enabled
if not disable_replication:
create_instances.append(template_instance)
# Set default values for any applicable custom fields
if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
for component in create_instances:
component.custom_field_data = cf_defaults
if component_model is not ModuleBay:
component_model.objects.bulk_create(create_instances)
# Emit the post_save signal for each newly created object
for component in create_instances:
post_save.send(
sender=component_model,
instance=component,
created=True,
raw=False,
using='default',
update_fields=None
)
else:
# ModuleBays must be saved individually for MPTT
for instance in create_instances:
instance.save()
update_fields = ['module']
component_model.objects.bulk_update(update_instances, update_fields)
# Emit the post_save signal for each updated object
for component in update_instances:
post_save.send(
sender=component_model,
instance=component,
created=False,
raw=False,
using='default',
update_fields=update_fields
)
# Interface bridges have to be set after interface instantiation
update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
#
# Virtual chassis
#

View 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)

View File

@ -183,6 +183,17 @@ class ModuleBayIndex(SearchIndex):
display_attrs = ('device', 'label', 'position', 'description')
@register_search
class ModuleTypeProfileIndex(SearchIndex):
model = models.ModuleTypeProfile
fields = (
('name', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('name', 'description')
@register_search
class ModuleTypeIndex(SearchIndex):
model = models.ModuleType

View File

@ -1,25 +1,64 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from dcim.models import Module, ModuleType
from dcim.models import Module, ModuleType, ModuleTypeProfile
from netbox.tables import NetBoxTable, columns
from .template_code import WEIGHT
from .template_code import MODULETYPEPROFILE_ATTRIBUTES, WEIGHT
__all__ = (
'ModuleTable',
'ModuleTypeProfileTable',
'ModuleTypeTable',
)
class ModuleTypeProfileTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
attributes = columns.TemplateColumn(
template_code=MODULETYPEPROFILE_ATTRIBUTES,
accessor=tables.A('schema__properties'),
orderable=False,
verbose_name=_('Attributes')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
tags = columns.TagColumn(
url_name='dcim:moduletypeprofile_list'
)
class Meta(NetBoxTable.Meta):
model = ModuleTypeProfile
fields = (
'pk', 'id', 'name', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'description', 'attributes',
)
class ModuleTypeTable(NetBoxTable):
model = tables.Column(
linkify=True,
verbose_name=_('Module Type')
profile = tables.Column(
verbose_name=_('Profile'),
linkify=True
)
manufacturer = tables.Column(
verbose_name=_('Manufacturer'),
linkify=True
)
model = tables.Column(
linkify=True,
verbose_name=_('Module Type')
)
weight = columns.TemplateColumn(
verbose_name=_('Weight'),
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
attributes = columns.DictColumn()
instance_count = columns.LinkedCountColumn(
viewname='dcim:module_list',
url_params={'module_type_id': 'pk'},
@ -31,20 +70,15 @@ class ModuleTypeTable(NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:moduletype_list'
)
weight = columns.TemplateColumn(
verbose_name=_('Weight'),
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
class Meta(NetBoxTable.Meta):
model = ModuleType
fields = (
'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags',
'created', 'last_updated',
'pk', 'id', 'model', 'profile', 'manufacturer', 'part_number', 'airflow', 'weight', 'description',
'attributes', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number',
'pk', 'model', 'profile', 'manufacturer', 'part_number',
)

View File

@ -568,3 +568,7 @@ MODULEBAY_BUTTONS = """
{% endif %}
{% endif %}
"""
MODULETYPEPROFILE_ATTRIBUTES = """
{% if value %}{% for attr in value %}{{ attr }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}
"""

View File

@ -591,7 +591,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
model = ModuleType
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'profile', 'url']
bulk_update_data = {
'part_number': 'ABC123',
}
@ -629,6 +629,70 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
]
class ModuleTypeProfileTest(APIViewTestCases.APIViewTestCase):
model = ModuleTypeProfile
brief_fields = ['description', 'display', 'id', 'name', 'url']
SCHEMAS = [
{
"properties": {
"foo": {
"type": "string"
}
}
},
{
"properties": {
"foo": {
"type": "integer"
}
}
},
{
"properties": {
"foo": {
"type": "boolean"
}
}
},
]
create_data = [
{
'name': 'Module Type Profile 4',
'schema': SCHEMAS[0],
},
{
'name': 'Module Type Profile 5',
'schema': SCHEMAS[1],
},
{
'name': 'Module Type Profile 6',
'schema': SCHEMAS[2],
},
]
bulk_update_data = {
'description': 'New description',
'comments': 'New comments',
}
@classmethod
def setUpTestData(cls):
module_type_profiles = (
ModuleTypeProfile(
name='Module Type Profile 1',
schema=cls.SCHEMAS[0]
),
ModuleTypeProfile(
name='Module Type Profile 2',
schema=cls.SCHEMAS[1]
),
ModuleTypeProfile(
name='Module Type Profile 3',
schema=cls.SCHEMAS[2]
),
)
ModuleTypeProfile.objects.bulk_create(module_type_profiles)
class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
model = ConsolePortTemplate
brief_fields = ['description', 'display', 'id', 'name', 'url']

View File

@ -1486,6 +1486,16 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ModuleType.objects.all()
filterset = ModuleTypeFilterSet
ignore_fields = ['attribute_data']
PROFILE_SCHEMA = {
"properties": {
"string": {"type": "string"},
"integer": {"type": "integer"},
"number": {"type": "number"},
"boolean": {"type": "boolean"},
}
}
@classmethod
def setUpTestData(cls):
@ -1496,6 +1506,21 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
)
Manufacturer.objects.bulk_create(manufacturers)
module_type_profiles = (
ModuleTypeProfile(
name='Module Type Profile 1',
schema=cls.PROFILE_SCHEMA
),
ModuleTypeProfile(
name='Module Type Profile 2',
schema=cls.PROFILE_SCHEMA
),
ModuleTypeProfile(
name='Module Type Profile 3',
schema=cls.PROFILE_SCHEMA
),
)
ModuleTypeProfile.objects.bulk_create(module_type_profiles)
module_types = (
ModuleType(
@ -1505,7 +1530,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
weight=10,
weight_unit=WeightUnitChoices.UNIT_POUND,
description='foobar1',
airflow=ModuleAirflowChoices.FRONT_TO_REAR
airflow=ModuleAirflowChoices.FRONT_TO_REAR,
profile=module_type_profiles[0],
attribute_data={
'string': 'string1',
'integer': 1,
'number': 1.0,
'boolean': True,
}
),
ModuleType(
manufacturer=manufacturers[1],
@ -1514,7 +1546,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
weight=20,
weight_unit=WeightUnitChoices.UNIT_POUND,
description='foobar2',
airflow=ModuleAirflowChoices.REAR_TO_FRONT
airflow=ModuleAirflowChoices.REAR_TO_FRONT,
profile=module_type_profiles[1],
attribute_data={
'string': 'string2',
'integer': 2,
'number': 2.0,
'boolean_': False,
}
),
ModuleType(
manufacturer=manufacturers[2],
@ -1522,7 +1561,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
part_number='Part Number 3',
weight=30,
weight_unit=WeightUnitChoices.UNIT_KILOGRAM,
description='foobar3'
description='foobar3',
profile=module_type_profiles[2],
attribute_data={
'string': 'string3',
'integer': 3,
'number': 3.0,
'boolean': None,
}
),
)
ModuleType.objects.bulk_create(module_types)
@ -1641,6 +1687,82 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'airflow': RackAirflowChoices.FRONT_TO_REAR}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_profile(self):
profiles = ModuleTypeProfile.objects.filter(name__startswith="Module Type Profile")[:2]
params = {'profile_id': [profiles[0].pk, profiles[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'profile': [profiles[0].name, profiles[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_profile_attributes(self):
params = {'attr_string': 'string1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'attr_integer': '1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'attr_number': '2.0'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'attr_boolean': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ModuleTypeProfileTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ModuleTypeProfile.objects.all()
filterset = ModuleTypeProfileFilterSet
ignore_fields = ['schema']
SCHEMAS = [
{
"properties": {
"foo": {
"type": "string"
}
}
},
{
"properties": {
"foo": {
"type": "integer"
}
}
},
{
"properties": {
"foo": {
"type": "boolean"
}
}
},
]
@classmethod
def setUpTestData(cls):
module_type_profiles = (
ModuleTypeProfile(
name='Module Type Profile 1',
description='foobar1',
schema=cls.SCHEMAS[0]
),
ModuleTypeProfile(
name='Module Type Profile 2',
description='foobar2 2',
schema=cls.SCHEMAS[1]
),
ModuleTypeProfile(
name='Module Type Profile 3',
description='foobar3',
schema=cls.SCHEMAS[2]
),
)
ModuleTypeProfile.objects.bulk_create(module_type_profiles)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Module Type Profile 1', 'Module Type Profile 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePortTemplate.objects.all()

View File

@ -1,3 +1,4 @@
import json
from decimal import Decimal
from zoneinfo import ZoneInfo
@ -1305,6 +1306,79 @@ front-ports:
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
class ModuleTypeProfileTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = ModuleTypeProfile
SCHEMAS = [
{
"properties": {
"foo": {
"type": "string"
}
}
},
{
"properties": {
"foo": {
"type": "integer"
}
}
},
{
"properties": {
"foo": {
"type": "boolean"
}
}
},
]
@classmethod
def setUpTestData(cls):
module_type_profiles = (
ModuleTypeProfile(
name='Module Type Profile 1',
schema=cls.SCHEMAS[0]
),
ModuleTypeProfile(
name='Module Type Profile 2',
schema=cls.SCHEMAS[1]
),
ModuleTypeProfile(
name='Module Type Profile 3',
schema=cls.SCHEMAS[2]
),
)
ModuleTypeProfile.objects.bulk_create(module_type_profiles)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Module Type Profile X',
'description': 'A new profile',
'schema': json.dumps(cls.SCHEMAS[0]),
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,schema",
f"Module Type Profile 4,{json.dumps(cls.SCHEMAS[0])}",
f"Module Type Profile 5,{json.dumps(cls.SCHEMAS[1])}",
f"Module Type Profile 6,{json.dumps(cls.SCHEMAS[2])}",
)
cls.csv_update_data = (
"id,description",
f"{module_type_profiles[0].pk},New description",
f"{module_type_profiles[1].pk},New description",
f"{module_type_profiles[2].pk},New description",
)
cls.bulk_edit_data = {
'description': 'New description',
}
#
# DeviceType components
#

View File

@ -37,6 +37,9 @@ urlpatterns = [
path('device-types/', include(get_model_urls('dcim', 'devicetype', detail=False))),
path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
path('module-type-profiles/', include(get_model_urls('dcim', 'moduletypeprofile', detail=False))),
path('module-type-profiles/<int:pk>/', include(get_model_urls('dcim', 'moduletypeprofile'))),
path('module-types/', include(get_model_urls('dcim', 'moduletype', detail=False))),
path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),

View File

@ -1,3 +1,4 @@
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
@ -56,3 +57,22 @@ def rebuild_paths(terminations):
for cp in cable_paths:
cp.delete()
create_cablepath(cp.origins)
def update_interface_bridges(device, interface_templates, module=None):
"""
Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
and applies it to the actual interfaces.
"""
Interface = apps.get_model('dcim', 'Interface')
for interface_template in interface_templates.exclude(bridge=None):
interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
if interface_template.bridge:
interface.bridge = Interface.objects.get(
device=device,
name=interface_template.bridge.resolve_name(module=module)
)
interface.full_clean()
interface.save()

View File

@ -1247,6 +1247,62 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
table = tables.DeviceTypeTable
#
# Module type profiles
#
@register_model_view(ModuleTypeProfile, 'list', path='', detail=False)
class ModuleTypeProfileListView(generic.ObjectListView):
queryset = ModuleTypeProfile.objects.annotate(
instance_count=count_related(ModuleType, 'profile')
)
filterset = filtersets.ModuleTypeProfileFilterSet
filterset_form = forms.ModuleTypeProfileFilterForm
table = tables.ModuleTypeProfileTable
@register_model_view(ModuleTypeProfile)
class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
queryset = ModuleTypeProfile.objects.all()
@register_model_view(ModuleTypeProfile, 'add', detail=False)
@register_model_view(ModuleTypeProfile, 'edit')
class ModuleTypeProfileEditView(generic.ObjectEditView):
queryset = ModuleTypeProfile.objects.all()
form = forms.ModuleTypeProfileForm
@register_model_view(ModuleTypeProfile, 'delete')
class ModuleTypeProfileDeleteView(generic.ObjectDeleteView):
queryset = ModuleTypeProfile.objects.all()
@register_model_view(ModuleTypeProfile, 'bulk_import', detail=False)
class ModuleTypeProfileBulkImportView(generic.BulkImportView):
queryset = ModuleTypeProfile.objects.all()
model_form = forms.ModuleTypeProfileImportForm
@register_model_view(ModuleTypeProfile, 'bulk_edit', path='edit', detail=False)
class ModuleTypeProfileBulkEditView(generic.BulkEditView):
queryset = ModuleTypeProfile.objects.annotate(
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeProfileFilterSet
table = tables.ModuleTypeProfileTable
form = forms.ModuleTypeProfileBulkEditForm
@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleTypeProfile.objects.annotate(
instance_count=count_related(Module, 'module_type')
)
filterset = filtersets.ModuleTypeProfileFilterSet
table = tables.ModuleTypeProfileTable
#
# Module types
#

View File

@ -1161,6 +1161,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'module',
'modulebay',
'moduletype',
'moduletypeprofile',
'platform',
'powerfeed',
'poweroutlet',

View File

@ -9,6 +9,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
__all__ = (
'AttributesField',
'ChoiceField',
'ContentTypeField',
'IPNetworkSerializer',
@ -172,3 +173,19 @@ class IntegerRangeSerializer(serializers.Serializer):
def to_representation(self, instance):
return instance.lower, instance.upper - 1
class AttributesField(serializers.JSONField):
"""
Custom attributes stored as JSON data.
"""
def to_internal_value(self, data):
data = super().to_internal_value(data)
# If updating an object, start with the initial attribute data. This enables the client to modify
# individual attributes without having to rewrite the entire field.
if data and self.parent.instance:
initial_data = getattr(self.parent.instance, self.source, None) or {}
return {**initial_data, **data}
return data

View File

@ -1,3 +1,5 @@
import json
import django_filters
from copy import deepcopy
from django.contrib.contenttypes.models import ContentType
@ -20,6 +22,7 @@ from utilities.forms.fields import MACAddressField
from utilities import filters
__all__ = (
'AttributeFiltersMixin',
'BaseFilterSet',
'ChangeLoggedModelFilterSet',
'NetBoxModelFilterSet',
@ -345,3 +348,32 @@ class NestedGroupModelFilterSet(NetBoxModelFilterSet):
)
return queryset
class AttributeFiltersMixin:
attributes_field_name = 'attribute_data'
attribute_filter_prefix = 'attr_'
def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
self.attr_filters = {}
# Extract JSONField-based filters from the incoming data
if data is not None:
for key, value in data.items():
if field := self._get_field_lookup(key):
# Attempt to cast the value to a native JSON type
try:
self.attr_filters[field] = json.loads(value)
except (ValueError, json.JSONDecodeError):
self.attr_filters[field] = value
super().__init__(data=data, queryset=queryset, request=request, prefix=prefix)
def _get_field_lookup(self, key):
if not key.startswith(self.attribute_filter_prefix):
return
lookup = key.split(self.attribute_filter_prefix, 1)[1] # Strip prefix
return f'{self.attributes_field_name}__{lookup}'
def filter_queryset(self, queryset):
return super().filter_queryset(queryset).filter(**self.attr_filters)

View File

@ -85,6 +85,7 @@ DEVICES_MENU = Menu(
items=(
get_model_item('dcim', 'devicetype', _('Device Types')),
get_model_item('dcim', 'moduletype', _('Module Types')),
get_model_item('dcim', 'moduletypeprofile', _('Module Type Profiles')),
get_model_item('dcim', 'manufacturer', _('Manufacturers')),
),
),

View File

@ -35,6 +35,7 @@ __all__ = (
'ContentTypesColumn',
'CustomFieldColumn',
'CustomLinkColumn',
'DictColumn',
'DistanceColumn',
'DurationColumn',
'LinkedCountColumn',
@ -707,3 +708,14 @@ class DistanceColumn(TemplateColumn):
def __init__(self, template_code=template_code, order_by='_abs_distance', **kwargs):
super().__init__(template_code=template_code, order_by=order_by, **kwargs)
class DictColumn(tables.Column):
"""
Render a dictionary of data in a simple key: value format, one pair per line.
"""
def render(self, value):
output = '<br />'.join([
f'{escape(k)}: {escape(v)}' for k, v in value.items()
])
return mark_safe(output)

View File

@ -1,8 +1,8 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load tz %}
{% load i18n %}
{% load mptt %}
{% block breadcrumbs %}
{{ block.super }}
@ -62,8 +62,8 @@
<td>{{ object.device.device_type|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Module Type" %}</th>
<td>{{ object.module_type|linkify:"full_name" }}</td>
<th scope="row">{% trans "Module Bay" %}</th>
<td>{% nested_tree object.module_bay %}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
@ -88,6 +88,25 @@
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Module Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.module_type.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Model" %}</th>
<td>{{ object.module_type|linkify }}</td>
</tr>
{% for k, v in object.module_type.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>{{ v|placeholder }}</td>
</tr>
{% endfor %}
</table>
</div>
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}

View File

@ -23,6 +23,10 @@
<div class="card">
<h2 class="card-header">{% trans "Module Type" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Profile" %}</th>
<td>{{ object.profile|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify }}</td>
@ -60,6 +64,27 @@
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Attributes" %}</h2>
{% if not object.profile %}
<div class="card-body text-muted">
{% trans "No profile assigned" %}
</div>
{% elif object.attributes %}
<table class="table table-hover attr-table">
{% for k, v in object.attributes.items %}
<tr>
<th scope="row">{{ k }}</th>
<td>{{ v|placeholder }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="card-body text-muted">
{% trans "None" %}
</div>
{% endif %}
</div>
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/image_attachments.html' %}

View 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 %}

View File

@ -136,9 +136,11 @@ def get_field_value(form, field_name):
"""
field = form.fields[field_name]
if form.is_bound and (data := form.data.get(field_name)):
if hasattr(field, 'valid_value') and field.valid_value(data):
return data
if form.is_bound and field_name in form.data:
if (value := form.data[field_name]) is None:
return
if hasattr(field, 'valid_value') and field.valid_value(value):
return value
return form.get_initial_for_field(field, field_name)

View 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))

View File

@ -1,10 +1,11 @@
from django import forms
from django.test import TestCase
from dcim.models import Site
from netbox.choices import ImportFormatChoices
from utilities.forms.bulk_import import BulkImportForm
from utilities.forms.forms import BulkRenameForm
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
class ExpandIPAddress(TestCase):
@ -387,3 +388,63 @@ class BulkRenameFormTest(TestCase):
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["find"], " hello ")
self.assertEqual(form.cleaned_data["replace"], " world ")
class GetFieldValueTest(TestCase):
@classmethod
def setUpTestData(cls):
class TestForm(forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False
)
cls.form_class = TestForm
cls.sites = (
Site(name='Test Site 1', slug='test-site-1'),
Site(name='Test Site 2', slug='test-site-2'),
)
Site.objects.bulk_create(cls.sites)
def test_unbound_without_initial(self):
form = self.form_class()
self.assertEqual(
get_field_value(form, 'site'),
None
)
def test_unbound_with_initial(self):
form = self.form_class(initial={'site': self.sites[0].pk})
self.assertEqual(
get_field_value(form, 'site'),
self.sites[0].pk
)
def test_bound_value_without_initial(self):
form = self.form_class({'site': self.sites[0].pk})
self.assertEqual(
get_field_value(form, 'site'),
self.sites[0].pk
)
def test_bound_value_with_initial(self):
form = self.form_class({'site': self.sites[0].pk}, initial={'site': self.sites[1].pk})
self.assertEqual(
get_field_value(form, 'site'),
self.sites[0].pk
)
def test_bound_null_without_initial(self):
form = self.form_class({'site': None})
self.assertEqual(
get_field_value(form, 'site'),
None
)
def test_bound_null_with_initial(self):
form = self.form_class({'site': None}, initial={'site': self.sites[1].pk})
self.assertEqual(
get_field_value(form, 'site'),
None
)

View File

@ -1,3 +1,4 @@
import decimal
import re
from django.core.exceptions import ValidationError
@ -10,6 +11,7 @@ __all__ = (
'ColorValidator',
'EnhancedURLValidator',
'ExclusionValidator',
'MultipleOfValidator',
'validate_regex',
)
@ -54,6 +56,22 @@ class ExclusionValidator(BaseValidator):
return a in b
class MultipleOfValidator(BaseValidator):
"""
Checks that a field's value is a numeric multiple of the given value. Both values are
cast as Decimals for comparison.
"""
def __init__(self, multiple):
self.multiple = decimal.Decimal(str(multiple))
super().__init__(limit_value=None)
def __call__(self, value):
if decimal.Decimal(str(value)) % self.multiple != 0:
raise ValidationError(
_("{value} must be a multiple of {multiple}.").format(value=value, multiple=self.multiple)
)
def validate_regex(value):
"""
Checks that the value is a valid regular expression. (Don't confuse this with RegexValidator, which *uses* a regex

View File

@ -20,6 +20,7 @@ drf-spectacular-sidecar==2025.2.1
feedparser==6.0.11
gunicorn==23.0.0
Jinja2==3.1.5
jsonschema==4.23.0
Markdown==3.7
mkdocs-material==9.6.7
mkdocstrings[python]==0.28.2