Files
NetBox-Concept/netbox_rack_concept/forms.py
T
MrBlakeandClaude Sonnet 5 63538c2efe Fix rear-face image-only labels, click-to-add units, cable concept devices
- Fix "images only" leaving plain labels visible on faces where a
  device has no image for that side (e.g. no rear_image set): the
  view selector now hides every plain label outright in that mode,
  not just the ones an image happens to sit on top of.
- Remove the "Add planned device" button from the elevation card.
  Empty units are now click targets themselves - clicking one opens
  the add-device form pre-filled with that position and face, exactly
  like a real rack's elevation. Occupancy is computed per unit per
  face from the existing device blocks.
- Add cabling for concept devices: ConceptComponent (interfaces,
  front/rear ports, power ports/outlets, console ports) and
  ConceptCable (a connection between two components, restricted to
  devices in the same concept). components.sync_components()
  populates a device's components from its device type's templates,
  including the front/rear port pass-through mapping copied from
  DeviceType.port_mappings - so a device with front/rear port
  templates (a patch panel being the common case) behaves exactly
  like a real one, without hardcoding anything role-specific.
  Placeholder devices without a device type get components added by
  hand. Full CRUD views, tables, filtersets, API endpoints and a
  "Connect" action wired in from the device detail page.

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

762 lines
26 KiB
Python

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')),
)