Files
NetBox-Concept/netbox_rack_concept/forms.py
T
MrBlakeandClaude Sonnet 5 e4817ea83b Add images/labels toggle, clean up label styling, merge real racks
- Replace the device-image label background bar and text-shadow with
  NetBox's own technique: a black-stroke pass behind a white-fill pass
  of the same text, giving crisp readable labels over any image
  without a dark box behind them.
- Add an "Images and labels / Images only / Labels only" selector
  above the elevation, mirroring the equivalent control on real NetBox
  rack elevations. The plain label is always in the markup; the
  selector only toggles the image and its over-image label, exactly
  like core NetBox does it. Choice persists per browser via
  localStorage and works without edit permissions.
- Add a "Merge into concept" button to the real dcim Rack list: select
  two racks via the existing bulk-select checkboxes and it forwards
  them to the concept-merge page, pre-filled. New
  deploy.merge_racks_to_concept() combines two real racks' devices
  directly into one new, stacked blueprint without an intermediate
  per-rack copy step.

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

634 lines
21 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',
'MergeRacksForm',
'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 MergeRacksForm(forms.Form):
"""Combine two existing racks directly into one new, stacked 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')
)
rack_bottom = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label=_('Bottom rack'),
help_text=_('Keeps its planned positions unchanged')
)
rack_top = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label=_('Top rack'),
help_text=_("Its devices are shifted up by the bottom rack's height")
)
include_devices = forms.BooleanField(
required=False,
initial=True,
label=_('Copy installed devices')
)
include_device_names = forms.BooleanField(
required=False,
initial=True,
label=_('Keep device names')
)
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('rack_bottom', 'rack_top', name=_('Sources')),
FieldSet('include_devices', 'include_device_names', name=_('Content')),
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('rack_bottom'), cleaned.get('rack_top')
if bottom and top and bottom.pk == top.pk:
raise forms.ValidationError(_('Choose two different racks 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