import re from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.utils.safestring import mark_safe from mptt.forms import TreeNodeChoiceField from netaddr import EUI from netaddr.core import AddrFormatError from taggit.forms import TagField from timezone_field import TimeZoneFormField from circuits.models import Circuit, Provider from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, LocalConfigContextFilterForm, ) from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * from .constants import * from .models import ( Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) DEVICE_BY_PK_RE = r'{\d+\}' INTERFACE_MODE_HELP_TEXT = """ Access: One untagged VLAN
Tagged: One untagged VLAN and/or one or more tagged VLANs
Tagged (All): Implies all VLANs are available (w/optional untagged VLAN) """ def get_device_by_name_or_pk(name): """ Attempt to retrieve a device by either its name or primary key ('{pk}'). """ if re.match(DEVICE_BY_PK_RE, name): pk = name.strip('{}') device = Device.objects.get(pk=pk) else: device = Device.objects.get(name=name) return device class DeviceComponentFilterForm(BootstrapMixin, forms.Form): field_order = [ 'q', 'region', 'site' ] q = forms.CharField( required=False, label='Search' ) region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( value_field='slug', filter_for={ 'site': 'region' } ) ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( value_field="slug", filter_for={ 'device_id': 'site', } ) ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device' ) class InterfaceCommonForm: def clean(self): super().clean() # Validate VLAN assignments tagged_vlans = self.cleaned_data['tagged_vlans'] # Untagged interfaces cannot be assigned tagged VLANs if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: raise forms.ValidationError({ 'mode': "An access interface cannot have tagged VLANs assigned." }) # Remove all tagged VLAN assignments from "tagged all" interfaces elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL: self.cleaned_data['tagged_vlans'] = [] # Validate tagged VLANs; must be a global VLAN or in the same site elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED: valid_sites = [None, self.cleaned_data['device'].site] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] if invalid_vlans: raise forms.ValidationError({ 'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent " "device/VM, or they must be global".format(', '.join(invalid_vlans)) }) class BulkRenameForm(forms.Form): """ An extendable form to be used for renaming device components in bulk. """ find = forms.CharField() replace = forms.CharField() use_regex = forms.BooleanField( required=False, initial=True, label='Use regular expressions' ) def clean(self): # Validate regular expression in "find" field if self.cleaned_data['use_regex']: try: re.compile(self.cleaned_data['find']) except re.error: raise forms.ValidationError({ 'find': "Invalid regular expression" }) # # Fields # class MACAddressField(forms.Field): widget = forms.CharField default_error_messages = { 'invalid': 'MAC address must be in EUI-48 format', } def to_python(self, value): value = super().to_python(value) # Validate MAC address format try: value = EUI(value.strip()) except AddrFormatError: raise forms.ValidationError(self.error_messages['invalid'], code='invalid') return value # # Regions # class RegionForm(BootstrapMixin, forms.ModelForm): parent = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, widget=StaticSelect2() ) slug = SlugField() class Meta: model = Region fields = ( 'parent', 'name', 'slug', 'description', ) class RegionCSVForm(CSVModelForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', help_text='Name of parent region' ) class Meta: model = Region fields = Region.csv_headers class RegionFilterForm(BootstrapMixin, forms.Form): model = Site q = forms.CharField( required=False, label='Search' ) # # Sites # class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, widget=StaticSelect2() ) slug = SlugField() comments = CommentField() tags = TagField( required=False ) class Meta: model = Site fields = [ 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'tags', ] widgets = { 'physical_address': SmallTextarea( attrs={ 'rows': 3, } ), 'shipping_address': SmallTextarea( attrs={ 'rows': 3, } ), 'status': StaticSelect2(), 'time_zone': StaticSelect2(), } help_texts = { 'name': "Full name of the site", 'facility': "Data center provider and facility (e.g. Equinix NY7)", 'asn': "BGP autonomous system number", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", 'physical_address': "Physical location of the building (e.g. for GPS)", 'shipping_address': "If different from the physical address", 'latitude': "Latitude in decimal format (xx.yyyyyy)", 'longitude': "Longitude in decimal format (xx.yyyyyy)" } class SiteCSVForm(CustomFieldModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, required=False, help_text='Operational status' ) region = CSVModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', help_text='Assigned region' ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', help_text='Assigned tenant' ) class Meta: model = Site fields = Site.csv_headers help_texts = { 'time_zone': mark_safe( 'Time zone (available options)' ) } class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Site.objects.all(), widget=forms.MultipleHiddenInput ) status = forms.ChoiceField( choices=add_blank_choice(SiteStatusChoices), required=False, initial='', widget=StaticSelect2() ) region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, widget=StaticSelect2() ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False ) asn = forms.IntegerField( min_value=BGP_ASN_MIN, max_value=BGP_ASN_MAX, required=False, label='ASN' ) description = forms.CharField( max_length=100, required=False ) time_zone = TimeZoneFormField( choices=add_blank_choice(TimeZoneFormField().choices), required=False, widget=StaticSelect2() ) class Meta: nullable_fields = [ 'region', 'tenant', 'asn', 'description', 'time_zone', ] class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Site field_order = ['q', 'status', 'region', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) status = forms.MultipleChoiceField( choices=SiteStatusChoices, required=False, widget=StaticSelect2Multiple() ) region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( value_field="slug", ) ) tag = TagFilterField(model) # # Rack groups # class RackGroupForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all() ) parent = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False ) slug = SlugField() class Meta: model = RackGroup fields = ( 'site', 'parent', 'name', 'slug', 'description', ) class RackGroupCSVForm(CSVModelForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', help_text='Assigned site' ) parent = CSVModelChoiceField( queryset=RackGroup.objects.all(), required=False, to_field_name='name', help_text='Parent rack group', error_messages={ 'invalid_choice': 'Rack group not found.', } ) class Meta: model = RackGroup fields = RackGroup.csv_headers class RackGroupFilterForm(BootstrapMixin, forms.Form): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( value_field="slug", filter_for={ 'site': 'region', 'parent': 'region', } ) ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( value_field="slug", filter_for={ 'parent': 'site', } ) ) parent = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", value_field="slug", ) ) # # Rack roles # class RackRoleForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: model = RackRole fields = [ 'name', 'slug', 'color', 'description', ] class RackRoleCSVForm(CSVModelForm): slug = SlugField() class Meta: model = RackRole fields = RackRole.csv_headers help_texts = { 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), } # # Racks # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( filter_for={ 'group': 'site_id', } ) ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), required=False ) comments = CommentField() tags = TagField( required=False ) class Meta: model = Rack fields = [ 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", 'name': "Organizational rack name", 'facility_id': "The unique rack ID assigned by the facility", 'u_height': "Height in rack units", } widgets = { 'status': StaticSelect2(), 'type': StaticSelect2(), 'width': StaticSelect2(), 'outer_unit': StaticSelect2(), } class RackCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name' ) group = CSVModelChoiceField( queryset=RackGroup.objects.all(), required=False, to_field_name='name' ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', help_text='Name of assigned tenant' ) status = CSVChoiceField( choices=RackStatusChoices, required=False, help_text='Operational status' ) role = CSVModelChoiceField( queryset=RackRole.objects.all(), required=False, to_field_name='name', help_text='Name of assigned role' ) type = CSVChoiceField( choices=RackTypeChoices, required=False, help_text='Rack type' ) width = forms.ChoiceField( choices=RackWidthChoices, help_text='Rail-to-rail width (in inches)' ) outer_unit = CSVChoiceField( choices=RackDimensionUnitChoices, required=False, help_text='Unit for outer dimensions' ) class Meta: model = Rack fields = Rack.csv_headers def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) if data: # Limit group queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} self.fields['group'].queryset = self.fields['group'].queryset.filter(**params) class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( filter_for={ 'group': 'site_id', } ) ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False ) status = forms.ChoiceField( choices=add_blank_choice(RackStatusChoices), required=False, initial='', widget=StaticSelect2() ) role = DynamicModelChoiceField( queryset=RackRole.objects.all(), required=False ) serial = forms.CharField( max_length=50, required=False, label='Serial Number' ) asset_tag = forms.CharField( max_length=50, required=False ) type = forms.ChoiceField( choices=add_blank_choice(RackTypeChoices), required=False, widget=StaticSelect2() ) width = forms.ChoiceField( choices=add_blank_choice(RackWidthChoices), required=False, widget=StaticSelect2() ) u_height = forms.IntegerField( required=False, label='Height (U)' ) desc_units = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect, label='Descending units' ) outer_width = forms.IntegerField( required=False, min_value=1 ) outer_depth = forms.IntegerField( required=False, min_value=1 ) outer_unit = forms.ChoiceField( choices=add_blank_choice(RackDimensionUnitChoices), required=False, widget=StaticSelect2() ) comments = CommentField( widget=SmallTextarea, label='Comments' ) class Meta: nullable_fields = [ 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ] class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Rack field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( value_field="slug", filter_for={ 'site': 'region' } ) ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( value_field="slug", filter_for={ 'group_id': 'site' } ) ) group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related( 'site' ), required=False, label='Rack group', widget=APISelectMultiple( null_option=True ) ) status = forms.MultipleChoiceField( choices=RackStatusChoices, required=False, widget=StaticSelect2Multiple() ) role = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), to_field_name='slug', required=False, widget=APISelectMultiple( value_field="slug", null_option=True, ) ) tag = TagFilterField(model) # # Rack elevations # class RackElevationFilterForm(RackFilterForm): field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), label='Rack', required=False, widget=APISelectMultiple( display_field='display_name', ) ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Filter the rack field based on the site and group self.fields['site'].widget.add_filter_for('id', 'site') self.fields['group_id'].widget.add_filter_for('id', 'group_id') # # Rack reservations # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): rack = forms.ModelChoiceField( queryset=Rack.objects.all(), required=False, widget=forms.HiddenInput() ) # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain # the multi-line