Files
NetBox-Concept/netbox_rack_concept/forms.py
T
MrBlakeandClaude Sonnet 5 777ded3900 Fix elevation overlap, add device images, rack merge, and size override
- Fix elevation rendering bug: device blocks were drawn MARGIN px
  further right than the rack frame/unit slots, causing them to
  overlap and extend past the right border. All three now share one
  RACK_X origin.
- Show the device type's front/rear image inside each elevation block
  (clipped to the block, translucent label bar on top for legibility)
  when one is set on the device type; falls back to a plain colour
  block with text otherwise.
- Add "Merge" action to combine two concepts into one taller one (e.g.
  two half-height racks stacked into a full-height rack): the top
  concept's devices are shifted up by the bottom concept's height,
  neither source is modified. Available from the plugin menu, from a
  concept's detail page, and via POST /concepts/merge/.
- Copying a real rack into a concept can now target a different width,
  height and/or starting unit than the source rack; devices that no
  longer fit at the chosen size are skipped and reported as warnings
  instead of aborting the copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 09:35:18 +02:00

554 lines
19 KiB
Python

from django import forms
from django.utils.translation import gettext_lazy as _
from dcim.choices import DeviceFaceChoices, RackAirflowChoices, RackFormFactorChoices, RackWidthChoices
from dcim.models import DeviceRole, DeviceType, Location, Platform, Rack, RackRole, RackType, Site
from netbox.forms import NetBoxModelBulkEditForm, NetBoxModelFilterSetForm, NetBoxModelForm, NetBoxModelImportForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms.fields import (
CommentField, CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
TagFilterField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
from .choices import ConceptDeviceStatusChoices, ConceptStatusChoices, WidthFractionChoices
from .models import ConceptDevice, RackConcept
__all__ = (
'ConceptDeployForm',
'ConceptDeviceBulkEditForm',
'ConceptDeviceFilterForm',
'ConceptDeviceForm',
'ConceptDeviceImportForm',
'MergeConceptsForm',
'RackConceptBulkEditForm',
'RackConceptCloneForm',
'RackConceptFilterForm',
'RackConceptForm',
'RackConceptImportForm',
'RackCopyForm',
)
class RackConceptForm(NetBoxModelForm):
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
initial_params={'tenants': '$tenant'},
label=_('Tenant group')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'},
label=_('Tenant')
)
rack_type = DynamicModelChoiceField(
queryset=RackType.objects.all(),
required=False,
label=_('Rack type')
)
rack_role = DynamicModelChoiceField(
queryset=RackRole.objects.all(),
required=False,
label=_('Rack role')
)
default_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label=_('Default site')
)
default_location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={'site_id': '$default_site'},
label=_('Default location')
)
comments = CommentField()
fieldsets = (
FieldSet('name', 'status', 'description', 'tags', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet(
'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'airflow',
name=_('Geometry')
),
FieldSet(
'outer_width', 'outer_height', 'outer_depth', 'outer_unit', 'mounting_depth', 'max_weight',
name=_('Dimensions')
),
FieldSet('default_site', 'default_location', 'rack_role', name=_('Deployment defaults')),
)
class Meta:
model = RackConcept
fields = (
'name', 'status', 'description', 'tenant_group', 'tenant', 'rack_type', 'form_factor', 'width',
'u_height', 'starting_unit', 'desc_units', 'airflow', 'outer_width', 'outer_height', 'outer_depth',
'outer_unit', 'mounting_depth', 'max_weight', 'default_site', 'default_location', 'rack_role',
'comments', 'tags',
)
class RackConceptFilterForm(NetBoxModelFilterSetForm):
model = RackConcept
status = forms.MultipleChoiceField(
choices=ConceptStatusChoices,
required=False
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant group')
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
width = forms.MultipleChoiceField(
choices=RackWidthChoices,
required=False
)
form_factor = forms.MultipleChoiceField(
choices=RackFormFactorChoices,
required=False
)
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('status', 'width', 'form_factor', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenancy')),
)
class RackConceptBulkEditForm(NetBoxModelBulkEditForm):
model = RackConcept
status = forms.ChoiceField(choices=ConceptStatusChoices, required=False)
tenant_group = DynamicModelChoiceField(queryset=TenantGroup.objects.all(), required=False)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'}
)
description = forms.CharField(max_length=200, required=False)
comments = CommentField()
nullable_fields = ('tenant_group', 'tenant', 'description', 'comments')
fieldsets = (
FieldSet('status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)
class RackConceptImportForm(NetBoxModelImportForm):
status = CSVChoiceField(choices=ConceptStatusChoices, required=False)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False
)
class Meta:
model = RackConcept
fields = (
'name', 'status', 'description', 'tenant', 'form_factor', 'width', 'u_height', 'starting_unit',
'desc_units', 'comments', 'tags',
)
class ConceptDeviceForm(NetBoxModelForm):
concept = DynamicModelChoiceField(
queryset=RackConcept.objects.all(),
label=_('Rack concept')
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Role')
)
platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
required=False,
label=_('Platform')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
comments = CommentField()
fieldsets = (
FieldSet('concept', 'name', 'label', 'status', 'description', 'tags', name=_('Planned device')),
FieldSet('device_type', 'role', 'platform', 'serial', name=_('Hardware')),
FieldSet('position', 'face', 'width_fraction', 'horizontal_position', name=_('Placement')),
FieldSet('u_height', 'is_full_depth', name=_('Overrides')),
FieldSet('tenant', name=_('Tenancy')),
)
class Meta:
model = ConceptDevice
fields = (
'concept', 'name', 'label', 'status', 'description', 'device_type', 'role', 'platform', 'serial',
'position', 'face', 'width_fraction', 'horizontal_position', 'u_height', 'is_full_depth', 'tenant',
'comments', 'tags',
)
class ConceptDeviceFilterForm(NetBoxModelFilterSetForm):
model = ConceptDevice
concept_id = DynamicModelMultipleChoiceField(
queryset=RackConcept.objects.all(),
required=False,
label=_('Rack concept')
)
status = forms.MultipleChoiceField(choices=ConceptDeviceStatusChoices, required=False)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Role')
)
face = forms.MultipleChoiceField(choices=DeviceFaceChoices, required=False)
tag = TagFilterField(model)
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('concept_id', 'status', 'face', name=_('Placement')),
FieldSet('device_type_id', 'role_id', name=_('Hardware')),
)
class ConceptDeviceBulkEditForm(NetBoxModelBulkEditForm):
model = ConceptDevice
status = forms.ChoiceField(choices=ConceptDeviceStatusChoices, required=False)
role = DynamicModelChoiceField(queryset=DeviceRole.objects.all(), required=False)
platform = DynamicModelChoiceField(queryset=Platform.objects.all(), required=False)
tenant = DynamicModelChoiceField(queryset=Tenant.objects.all(), required=False)
face = forms.ChoiceField(choices=DeviceFaceChoices, required=False)
width_fraction = forms.ChoiceField(choices=WidthFractionChoices, required=False)
is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect())
description = forms.CharField(max_length=200, required=False)
nullable_fields = ('role', 'platform', 'tenant', 'description', 'is_full_depth')
fieldsets = (
FieldSet('status', 'role', 'platform', 'description', name=_('Planned device')),
FieldSet('face', 'width_fraction', 'is_full_depth', name=_('Placement')),
FieldSet('tenant', name=_('Tenancy')),
)
class ConceptDeviceImportForm(NetBoxModelImportForm):
concept = CSVModelChoiceField(
queryset=RackConcept.objects.all(),
to_field_name='name'
)
device_type = CSVModelChoiceField(
queryset=DeviceType.objects.all(),
to_field_name='model',
required=False
)
role = CSVModelChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='name',
required=False
)
status = CSVChoiceField(choices=ConceptDeviceStatusChoices, required=False)
face = CSVChoiceField(choices=DeviceFaceChoices, required=False)
class Meta:
model = ConceptDevice
fields = (
'concept', 'name', 'label', 'status', 'device_type', 'role', 'position', 'face', 'width_fraction',
'horizontal_position', 'u_height', 'is_full_depth', 'description', 'tags',
)
#
# Action forms
#
class RackCopyForm(forms.Form):
"""Copy an existing dcim.Rack into a new blueprint."""
name = forms.CharField(
label=_('Concept name'),
max_length=100
)
status = forms.ChoiceField(
choices=ConceptStatusChoices,
initial=ConceptStatusChoices.STATUS_DRAFT
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant group')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'},
label=_('Tenant')
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy installed devices'),
help_text=_('Snapshot every device currently mounted in the rack')
)
include_device_names = forms.BooleanField(
required=False,
initial=True,
label=_('Keep device names'),
help_text=_('Unchecked, only the device type and position are copied')
)
override_width = forms.ChoiceField(
choices=(('', _('Same as rack')),) + tuple(RackWidthChoices),
required=False,
label=_('Width')
)
override_u_height = forms.IntegerField(
required=False,
min_value=1,
max_value=100,
label=_('Height (U)'),
help_text=_('Leave empty to keep the rack\'s own height. Devices that no longer fit are left out.')
)
override_starting_unit = forms.IntegerField(
required=False,
min_value=1,
label=_('Starting unit'),
help_text=_('Leave empty to keep the rack\'s own starting unit')
)
description = forms.CharField(max_length=200, required=False)
fieldsets = (
FieldSet('name', 'status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('include_devices', 'include_device_names', name=_('Content')),
FieldSet(
'override_width', 'override_u_height', 'override_starting_unit',
name=_('Concept size (optional)')
),
)
def clean_override_width(self):
value = self.cleaned_data['override_width']
return int(value) if value else None
class MergeConceptsForm(forms.Form):
"""Stack two blueprints (e.g. two half-height racks) into one taller concept."""
name = forms.CharField(
label=_('New concept name'),
max_length=100
)
status = forms.ChoiceField(
choices=ConceptStatusChoices,
initial=ConceptStatusChoices.STATUS_DRAFT
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_('Tenant group')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={'group_id': '$tenant_group'},
label=_('Tenant')
)
concept_bottom = DynamicModelChoiceField(
queryset=RackConcept.objects.all(),
label=_('Bottom concept'),
help_text=_('Keeps its planned positions unchanged')
)
concept_top = DynamicModelChoiceField(
queryset=RackConcept.objects.all(),
label=_('Top concept'),
help_text=_("Its devices are shifted up by the bottom concept's height")
)
width = forms.ChoiceField(
choices=RackWidthChoices,
initial=RackWidthChoices.WIDTH_19IN,
label=_('Width of the combined rack')
)
u_height = forms.IntegerField(
required=False,
min_value=1,
max_value=100,
label=_('Height (U)'),
help_text=_('Leave empty to use the sum of both heights')
)
starting_unit = forms.IntegerField(
initial=1,
min_value=1,
label=_('Starting unit')
)
description = forms.CharField(max_length=200, required=False)
fieldsets = (
FieldSet('name', 'status', 'description', name=_('Concept')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet('concept_bottom', 'concept_top', name=_('Sources')),
FieldSet('width', 'u_height', 'starting_unit', name=_('Combined size')),
)
def clean_width(self):
return int(self.cleaned_data['width'])
def clean(self):
cleaned = super().clean()
bottom, top = cleaned.get('concept_bottom'), cleaned.get('concept_top')
if bottom and top and bottom.pk == top.pk:
raise forms.ValidationError(_('Choose two different concepts to merge.'))
return cleaned
class RackConceptCloneForm(forms.Form):
"""Duplicate a blueprint, for building variants."""
name = forms.CharField(
label=_('New concept name'),
max_length=100
)
status = forms.ChoiceField(
choices=ConceptStatusChoices,
initial=ConceptStatusChoices.STATUS_DRAFT
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy planned devices')
)
fieldsets = (
FieldSet('name', 'status', 'tenant', 'include_devices'),
)
class ConceptDeployForm(forms.Form):
"""Materialise a blueprint as one or more real racks."""
name_pattern = forms.CharField(
label=_('Rack name'),
max_length=100,
help_text=_('Use {n} for the sequence number, e.g. "DC1-R{n}". Zero-pad with {n:02d}.')
)
count = forms.IntegerField(
label=_('Number of racks'),
min_value=1,
max_value=100,
initial=1
)
start_index = forms.IntegerField(
label=_('First sequence number'),
initial=1
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label=_('Site')
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={'site_id': '$site'},
label=_('Location')
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
create_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Create devices'),
help_text=_('Create a real device for every planned device that has a device type and a position')
)
device_name_pattern = forms.CharField(
max_length=100,
required=False,
label=_('Device name'),
help_text=_('Optional. {rack} and {name} are replaced, e.g. "{rack}-{name}". '
'Empty keeps the planned name.')
)
skip_unnamed_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Skip devices without a name'),
help_text=_('Unchecked, unnamed planned devices are created as unnamed devices')
)
fieldsets = (
FieldSet('name_pattern', 'count', 'start_index', name=_('Racks')),
FieldSet('site', 'location', 'tenant', name=_('Placement')),
FieldSet('create_devices', 'device_name_pattern', 'skip_unnamed_devices', name=_('Devices')),
)
def __init__(self, *args, concept=None, **kwargs):
super().__init__(*args, **kwargs)
self.concept = concept
if concept and not self.is_bound:
self.fields['name_pattern'].initial = concept.name
if concept.default_site:
self.fields['site'].initial = concept.default_site.pk
if concept.default_location:
self.fields['location'].initial = concept.default_location.pk
if concept.tenant:
self.fields['tenant'].initial = concept.tenant.pk
def clean_name_pattern(self):
pattern = self.cleaned_data['name_pattern']
try:
pattern.format(n=1)
except (IndexError, KeyError, ValueError) as exc:
raise forms.ValidationError(
_('Invalid name pattern: {error}').format(error=exc)
)
return pattern
def clean(self):
cleaned = super().clean()
pattern = cleaned.get('name_pattern')
count = cleaned.get('count')
start = cleaned.get('start_index')
site = cleaned.get('site')
if not (pattern and count and start is not None and site):
return cleaned
names = [pattern.format(n=i) for i in range(start, start + count)]
if len(set(names)) != len(names):
raise forms.ValidationError({
'name_pattern': _('The pattern produces duplicate names. Include {n} when creating more than one rack.')
})
clashes = Rack.objects.filter(site=site, name__in=names).values_list('name', flat=True)
if clashes:
raise forms.ValidationError({
'name_pattern': _('These racks already exist at {site}: {names}').format(
site=site, names=', '.join(sorted(clashes))
)
})
cleaned['rack_names'] = names
return cleaned