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 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, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, 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(forms.ModelForm): parent = forms.ModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', help_text='Name of parent region', error_messages={ 'invalid_choice': 'Region not found.', } ) class Meta: model = Region fields = Region.csv_headers help_texts = { 'name': 'Region name', 'slug': 'URL-friendly slug', } 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 = forms.ModelChoiceField( queryset=Region.objects.all(), required=False, to_field_name='name', help_text='Name of assigned region', error_messages={ 'invalid_choice': 'Region not found.', } ) tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', help_text='Name of assigned tenant', error_messages={ 'invalid_choice': 'Tenant not found.', } ) class Meta: model = Site fields = Site.csv_headers help_texts = { 'name': 'Site name', 'slug': 'URL-friendly slug', 'asn': '32-bit autonomous system number', } 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() ) slug = SlugField() class Meta: model = RackGroup fields = ( 'site', 'parent', 'name', 'slug', 'description', ) class RackGroupCSVForm(forms.ModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', help_text='Name of parent site', error_messages={ 'invalid_choice': 'Site not found.', } ) parent = forms.ModelChoiceField( queryset=RackGroup.objects.all(), required=False, to_field_name='name', help_text='Name of parent rack group', error_messages={ 'invalid_choice': 'Rack group not found.', } ) class Meta: model = RackGroup fields = RackGroup.csv_headers help_texts = { 'name': 'Name of rack group', 'slug': 'URL-friendly slug', } 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(forms.ModelForm): slug = SlugField() class Meta: model = RackRole fields = RackRole.csv_headers help_texts = { 'name': 'Name of rack role', 'color': '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 = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', help_text='Name of parent site', error_messages={ 'invalid_choice': 'Site not found.', } ) group_name = forms.CharField( help_text='Name of rack group', required=False ) tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, to_field_name='name', help_text='Name of assigned tenant', error_messages={ 'invalid_choice': 'Tenant not found.', } ) status = CSVChoiceField( choices=RackStatusChoices, required=False, help_text='Operational status' ) role = forms.ModelChoiceField( queryset=RackRole.objects.all(), required=False, to_field_name='name', help_text='Name of assigned role', error_messages={ 'invalid_choice': 'Role not found.', } ) 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 help_texts = { 'name': 'Rack name', 'u_height': 'Height in rack units', } def clean(self): super().clean() site = self.cleaned_data.get('site') group_name = self.cleaned_data.get('group_name') name = self.cleaned_data.get('name') facility_id = self.cleaned_data.get('facility_id') # Validate rack group if group_name: try: self.instance.group = RackGroup.objects.get(site=site, name=group_name) except RackGroup.DoesNotExist: raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site)) # Validate uniqueness of rack name within group if Rack.objects.filter(group=self.instance.group, name=name).exists(): raise forms.ValidationError( "A rack named {} already exists within group {}".format(name, group_name) ) # Validate uniqueness of facility ID within group if facility_id and Rack.objects.filter(group=self.instance.group, facility_id=facility_id).exists(): raise forms.ValidationError( "A rack with the facility ID {} already exists within group {}".format(facility_id, group_name) ) 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