from django import forms from django.utils.translation import gettext_lazy as _ from dcim.choices import CableTypeChoices, DeviceFaceChoices, LinkStatusChoices, 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 ConceptComponentTypeChoices, ConceptDeviceStatusChoices, ConceptStatusChoices, \ WidthFractionChoices from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept __all__ = ( 'ConceptCableForm', 'ConceptCableFilterForm', 'ConceptComponentForm', 'ConceptComponentFilterForm', '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 # # Cabling # class ConceptComponentForm(NetBoxModelForm): device = DynamicModelChoiceField( queryset=ConceptDevice.objects.all(), label=_('Concept device') ) fieldsets = ( FieldSet('device', 'component_type', 'name', 'label', 'description', 'tags', name=_('Component')), FieldSet('port_type', 'positions', 'color', name=_('Attributes')), ) class Meta: model = ConceptComponent fields = ( 'device', 'component_type', 'name', 'label', 'port_type', 'positions', 'color', 'description', 'tags', ) class ConceptComponentFilterForm(NetBoxModelFilterSetForm): model = ConceptComponent device_id = DynamicModelMultipleChoiceField( queryset=ConceptDevice.objects.all(), required=False, label=_('Concept device') ) concept_id = DynamicModelMultipleChoiceField( queryset=RackConcept.objects.all(), required=False, label=_('Rack concept') ) component_type = forms.MultipleChoiceField(choices=ConceptComponentTypeChoices, required=False) connected = forms.NullBooleanField(required=False, widget=forms.Select(choices=( ('', '---------'), ('true', _('Connected')), ('false', _('Not connected')), ))) tag = TagFilterField(model) fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('device_id', 'concept_id', 'component_type', 'connected', name=_('Attributes')), ) class ConceptCableForm(NetBoxModelForm): """ Mirrors the shape of NetBox's own cable-connect form: pick a device, then a component on it, for each end. """ concept = DynamicModelChoiceField( queryset=RackConcept.objects.all(), label=_('Rack concept'), help_text=_('Both ends must belong to devices in this concept') ) termination_a_device = DynamicModelChoiceField( queryset=ConceptDevice.objects.all(), label=_('Device A'), query_params={'concept_id': '$concept'} ) termination_a = DynamicModelChoiceField( queryset=ConceptComponent.objects.all(), label=_('Component A'), query_params={'device_id': '$termination_a_device', 'connected': 'false'} ) termination_b_device = DynamicModelChoiceField( queryset=ConceptDevice.objects.all(), label=_('Device B'), query_params={'concept_id': '$concept'} ) termination_b = DynamicModelChoiceField( queryset=ConceptComponent.objects.all(), label=_('Component B'), query_params={'device_id': '$termination_b_device', 'connected': 'false'} ) comments = CommentField() fieldsets = ( FieldSet('concept', name=_('Scope')), FieldSet('termination_a_device', 'termination_a', name=_('Side A')), FieldSet('termination_b_device', 'termination_b', name=_('Side B')), FieldSet('status', 'type', 'color', 'label', 'description', 'tags', name=_('Cable')), ) class Meta: model = ConceptCable fields = ( 'termination_a', 'termination_b', 'status', 'type', 'color', 'label', 'description', 'comments', 'tags', ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # concept/termination_*_device are UI-only helpers for the cascading selects # above, not model fields - when editing an existing cable, seed them from the # components already chosen so the dropdowns show the right device on load. instance = self.instance if instance and instance.pk: self.fields['concept'].initial = instance.termination_a.device.concept_id self.fields['termination_a_device'].initial = instance.termination_a.device_id self.fields['termination_b_device'].initial = instance.termination_b.device_id class ConceptCableFilterForm(NetBoxModelFilterSetForm): model = ConceptCable concept_id = DynamicModelMultipleChoiceField( queryset=RackConcept.objects.all(), required=False, label=_('Rack concept') ) status = forms.MultipleChoiceField(choices=LinkStatusChoices, required=False) type = forms.MultipleChoiceField(choices=CableTypeChoices, required=False) tag = TagFilterField(model) fieldsets = ( FieldSet('q', 'filter_id', 'tag'), FieldSet('concept_id', 'status', 'type', name=_('Attributes')), )