- 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>
634 lines
21 KiB
Python
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
|