Explorar el Código

Merge pull request #4524 from netbox-community/4139-component-bulk-create

Fixes #4139: Extend forms for bulk device component creation
Jeremy Stretch hace 5 años
padre
commit
ca08125d55

+ 397 - 432
netbox/dcim/forms.py

@@ -23,8 +23,9 @@ from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
     BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField,
     BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK,
-    SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
+    DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, form_from_model, JSONField,
+    SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine
 from .choices import *
 from .choices import *
@@ -2298,30 +2299,10 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
         label='Name'
         label='Name'
     )
     )
 
 
-
-class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
-    type = forms.ChoiceField(
-        choices=InterfaceTypeChoices,
-        widget=StaticSelect2()
-    )
-    enabled = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-    mtu = forms.IntegerField(
-        required=False,
-        min_value=INTERFACE_MTU_MIN,
-        max_value=INTERFACE_MTU_MAX,
-        label='MTU'
-    )
-    mgmt_only = forms.BooleanField(
-        required=False,
-        label='Management only'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
+    def clean_tags(self):
+        # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
+        # must first convert the list of tags to a string.
+        return ','.join(self.cleaned_data.get('tags'))
 
 
 
 
 #
 #
@@ -2375,20 +2356,23 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
-class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class ConsolePortBulkCreateForm(
+    form_from_model(ConsolePort, ['type', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class ConsolePortBulkEditForm(
+    form_from_model(ConsolePort, ['type', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsolePort.objects.all(),
         queryset=ConsolePort.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
 
 
     class Meta:
     class Meta:
         nullable_fields = (
         nullable_fields = (
@@ -2462,20 +2446,23 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
-class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class ConsoleServerPortBulkCreateForm(
+    form_from_model(ConsoleServerPort, ['type', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class ConsoleServerPortBulkEditForm(
+    form_from_model(ConsoleServerPort, ['type', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsoleServerPort.objects.all(),
         queryset=ConsoleServerPort.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(ConsolePortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
 
 
     class Meta:
     class Meta:
         nullable_fields = [
         nullable_fields = [
@@ -2573,30 +2560,23 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
-class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class PowerPortBulkCreateForm(
+    form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class PowerPortBulkEditForm(
+    form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=PowerPort.objects.all(),
         queryset=PowerPort.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerPortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    maximum_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Maximum draw in watts"
-    )
-    allocated_draw = forms.IntegerField(
-        min_value=1,
-        required=False,
-        help_text="Allocated draw in watts"
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
 
 
     class Meta:
     class Meta:
         nullable_fields = (
         nullable_fields = (
@@ -2700,6 +2680,61 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form):
         self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
         self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
 
 
 
 
+class PowerOutletBulkCreateForm(
+    form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class PowerOutletBulkEditForm(
+    form_from_model(PowerOutlet, ['type', 'feed_leg', 'power_port', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = [
+            'type', 'feed_leg', 'power_port', 'description',
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port queryset to PowerPorts which belong to the parent Device
+        if 'device' in self.initial:
+            device = Device.objects.filter(pk=self.initial['device']).first()
+            self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
+        else:
+            self.fields['power_port'].choices = ()
+            self.fields['power_port'].widget.attrs['disabled'] = True
+
+
+class PowerOutletBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
+class PowerOutletBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
 class PowerOutletCSVForm(forms.ModelForm):
 class PowerOutletCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -2750,65 +2785,6 @@ class PowerOutletCSVForm(forms.ModelForm):
             self.fields['power_port'].queryset = PowerPort.objects.none()
             self.fields['power_port'].queryset = PowerPort.objects.none()
 
 
 
 
-class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        disabled=True,
-        widget=forms.HiddenInput()
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletTypeChoices),
-        required=False
-    )
-    feed_leg = forms.ChoiceField(
-        choices=add_blank_choice(PowerOutletFeedLegChoices),
-        required=False,
-    )
-    power_port = forms.ModelChoiceField(
-        queryset=PowerPort.objects.all(),
-        required=False
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'type', 'feed_leg', 'power_port', 'description',
-        ]
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port queryset to PowerPorts which belong to the parent Device
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
-            self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
-        else:
-            self.fields['power_port'].choices = ()
-            self.fields['power_port'].widget.attrs['disabled'] = True
-
-
-class PowerOutletBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-
-
-class PowerOutletBulkDisconnectForm(ConfirmationForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=PowerOutlet.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-
-
 #
 #
 # Interfaces
 # Interfaces
 #
 #
@@ -2985,6 +2961,102 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
         self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
         self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
 
 
 
 
+class InterfaceBulkCreateForm(
+    form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class InterfaceBulkEditForm(
+    form_from_model(Interface, ['type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Interface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        disabled=True,
+        widget=forms.HiddenInput()
+    )
+    untagged_vlan = DynamicModelChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelect(
+            display_field='display_name',
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
+        )
+    )
+    tagged_vlans = DynamicModelMultipleChoiceField(
+        queryset=VLAN.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            display_field='display_name',
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
+        )
+    )
+
+    class Meta:
+        nullable_fields = [
+            'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit LAG choices to interfaces which belong to the parent device (or VC master)
+        if 'device' in self.initial:
+            device = Device.objects.filter(pk=self.initial['device']).first()
+            self.fields['lag'].queryset = Interface.objects.filter(
+                device__in=[device, device.get_vc_master()],
+                type=InterfaceTypeChoices.TYPE_LAG
+            )
+
+            # Add current site to VLANs query params
+            self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
+            self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
+        else:
+            self.fields['lag'].choices = ()
+            self.fields['lag'].widget.attrs['disabled'] = True
+
+    def clean(self):
+
+        # Untagged interfaces cannot be assigned tagged VLANs
+        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['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'] = []
+
+
+class InterfaceBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Interface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+
+class InterfaceBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Interface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+
 class InterfaceCSVForm(forms.ModelForm):
 class InterfaceCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -3052,129 +3124,6 @@ class InterfaceCSVForm(forms.ModelForm):
             return self.cleaned_data['enabled']
             return self.cleaned_data['enabled']
 
 
 
 
-class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        disabled=True,
-        widget=forms.HiddenInput()
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    enabled = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect()
-    )
-    lag = forms.ModelChoiceField(
-        queryset=Interface.objects.all(),
-        required=False,
-        label='Parent LAG',
-        widget=StaticSelect2()
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC Address'
-    )
-    mtu = forms.IntegerField(
-        required=False,
-        min_value=INTERFACE_MTU_MIN,
-        max_value=INTERFACE_MTU_MAX,
-        label='MTU'
-    )
-    mgmt_only = forms.NullBooleanField(
-        required=False,
-        widget=BulkEditNullBooleanSelect(),
-        label='Management only'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-    mode = forms.ChoiceField(
-        choices=add_blank_choice(InterfaceModeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    untagged_vlan = DynamicModelChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        widget=APISelect(
-            display_field='display_name',
-            full=True,
-            additional_query_params={
-                'site_id': 'null',
-            },
-        )
-    )
-    tagged_vlans = DynamicModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        required=False,
-        widget=APISelectMultiple(
-            display_field='display_name',
-            full=True,
-            additional_query_params={
-                'site_id': 'null',
-            },
-        )
-    )
-
-    class Meta:
-        nullable_fields = [
-            'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
-        ]
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit LAG choices to interfaces which belong to the parent device (or VC master)
-        if 'device' in self.initial:
-            device = Device.objects.filter(pk=self.initial['device']).first()
-            self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[device, device.get_vc_master()],
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
-
-            # Add current site to VLANs query params
-            self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
-            self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
-        else:
-            self.fields['lag'].choices = ()
-            self.fields['lag'].widget.attrs['disabled'] = True
-
-    def clean(self):
-
-        # Untagged interfaces cannot be assigned tagged VLANs
-        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['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'] = []
-
-
-class InterfaceBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-
-class InterfaceBulkDisconnectForm(ConfirmationForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-
 #
 #
 # Front pass-through ports
 # Front pass-through ports
 #
 #
@@ -3283,6 +3232,44 @@ class FrontPortCreateForm(BootstrapMixin, forms.Form):
         }
         }
 
 
 
 
+# class FrontPortBulkCreateForm(
+#     form_from_model(FrontPort, ['type', 'description', 'tags']),
+#     DeviceBulkAddComponentForm
+# ):
+#     pass
+
+
+class FrontPortBulkEditForm(
+    form_from_model(FrontPort, ['type', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]
+
+
+class FrontPortBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
+class FrontPortBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=FrontPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
 class FrontPortCSVForm(forms.ModelForm):
 class FrontPortCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -3331,41 +3318,6 @@ class FrontPortCSVForm(forms.ModelForm):
             self.fields['rear_port'].queryset = RearPort.objects.none()
             self.fields['rear_port'].queryset = RearPort.objects.none()
 
 
 
 
-class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=FrontPort.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = [
-            'description',
-        ]
-
-
-class FrontPortBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=FrontPort.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-
-
-class FrontPortBulkDisconnectForm(ConfirmationForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=FrontPort.objects.all(),
-        widget=forms.MultipleHiddenInput
-    )
-
-
 #
 #
 # Rear pass-through ports
 # Rear pass-through ports
 #
 #
@@ -3418,6 +3370,44 @@ class RearPortCreateForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
+class RearPortBulkCreateForm(
+    form_from_model(RearPort, ['type', 'positions', 'description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    pass
+
+
+class RearPortBulkEditForm(
+    form_from_model(RearPort, ['type', 'description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]
+
+
+class RearPortBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
+class RearPortBulkDisconnectForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=RearPort.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+
+
 class RearPortCSVForm(forms.ModelForm):
 class RearPortCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -3436,40 +3426,145 @@ class RearPortCSVForm(forms.ModelForm):
         fields = RearPort.csv_headers
         fields = RearPort.csv_headers
 
 
 
 
-class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RearPort.objects.all(),
-        widget=forms.MultipleHiddenInput()
+#
+# Device bays
+#
+
+class DeviceBayFilterForm(DeviceComponentFilterForm):
+    model = DeviceBay
+    tag = TagFilterField(model)
+
+
+class DeviceBayForm(BootstrapMixin, forms.ModelForm):
+    tags = TagField(
+        required=False
     )
     )
-    type = forms.ChoiceField(
-        choices=add_blank_choice(PortTypeChoices),
-        required=False,
-        widget=StaticSelect2()
+
+    class Meta:
+        model = DeviceBay
+        fields = [
+            'device', 'name', 'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
+class DeviceBayCreateForm(BootstrapMixin, forms.Form):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.prefetch_related('device_type__manufacturer')
     )
     )
-    description = forms.CharField(
-        max_length=100,
+    name_pattern = ExpandableNameField(
+        label='Name'
+    )
+    tags = TagField(
         required=False
         required=False
     )
     )
 
 
+
+class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
+    installed_device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        label='Child Device',
+        help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
+        widget=StaticSelect2(),
+    )
+
+    def __init__(self, device_bay, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        self.fields['installed_device'].queryset = Device.objects.filter(
+            site=device_bay.device.site,
+            rack=device_bay.device.rack,
+            parent_bay__isnull=True,
+            device_type__u_height=0,
+            device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
+        ).exclude(pk=device_bay.device.pk)
+
+
+class DeviceBayBulkCreateForm(
+    form_from_model(DeviceBay, ['description', 'tags']),
+    DeviceBulkAddComponentForm
+):
+    tags = TagField(
+        required=False
+    )
+
+
+class DeviceBayBulkEditForm(
+    form_from_model(DeviceBay, ['description']),
+    BootstrapMixin,
+    AddRemoveTagsForm,
+    BulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=DeviceBay.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
     class Meta:
     class Meta:
-        nullable_fields = [
+        nullable_fields = (
             'description',
             'description',
-        ]
+        )
 
 
 
 
-class RearPortBulkRenameForm(BulkRenameForm):
+class DeviceBayBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
-        queryset=RearPort.objects.all(),
-        widget=forms.MultipleHiddenInput
+        queryset=DeviceBay.objects.all(),
+        widget=forms.MultipleHiddenInput()
     )
     )
 
 
 
 
-class RearPortBulkDisconnectForm(ConfirmationForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=RearPort.objects.all(),
-        widget=forms.MultipleHiddenInput
+class DeviceBayCSVForm(forms.ModelForm):
+    device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Device not found.',
+        }
+    )
+    installed_device = FlexibleModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text='Name or ID of device',
+        error_messages={
+            'invalid_choice': 'Child device not found.',
+        }
     )
     )
 
 
+    class Meta:
+        model = DeviceBay
+        fields = DeviceBay.csv_headers
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit installed device choices to devices of the correct type and location
+        if self.is_bound:
+            try:
+                device = self.fields['device'].to_python(self.data['device'])
+            except forms.ValidationError:
+                device = None
+        else:
+            try:
+                device = self.instance.device
+            except Device.DoesNotExist:
+                device = None
+
+        if device:
+            self.fields['installed_device'].queryset = Device.objects.filter(
+                site=device.site,
+                rack=device.rack,
+                parent_bay__isnull=True,
+                device_type__u_height=0,
+                device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
+            ).exclude(pk=device.pk)
+        else:
+            self.fields['installed_device'].queryset = Interface.objects.none()
+
 
 
 #
 #
 # Cables
 # Cables
@@ -3954,136 +4049,6 @@ class CableFilterForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
-#
-# Device bays
-#
-
-class DeviceBayFilterForm(DeviceComponentFilterForm):
-    model = DeviceBay
-    tag = TagFilterField(model)
-
-
-class DeviceBayForm(BootstrapMixin, forms.ModelForm):
-    tags = TagField(
-        required=False
-    )
-
-    class Meta:
-        model = DeviceBay
-        fields = [
-            'device', 'name', 'description', 'tags',
-        ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
-
-
-class DeviceBayCreateForm(BootstrapMixin, forms.Form):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.prefetch_related('device_type__manufacturer')
-    )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
-    tags = TagField(
-        required=False
-    )
-
-
-class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
-    installed_device = forms.ModelChoiceField(
-        queryset=Device.objects.all(),
-        label='Child Device',
-        help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
-        widget=StaticSelect2(),
-    )
-
-    def __init__(self, device_bay, *args, **kwargs):
-
-        super().__init__(*args, **kwargs)
-
-        self.fields['installed_device'].queryset = Device.objects.filter(
-            site=device_bay.device.site,
-            rack=device_bay.device.rack,
-            parent_bay__isnull=True,
-            device_type__u_height=0,
-            device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
-        ).exclude(pk=device_bay.device.pk)
-
-
-class DeviceBayBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=DeviceBay.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )
-
-    class Meta:
-        nullable_fields = (
-            'description',
-        )
-
-
-class DeviceBayCSVForm(forms.ModelForm):
-    device = FlexibleModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        help_text='Name or ID of device',
-        error_messages={
-            'invalid_choice': 'Device not found.',
-        }
-    )
-    installed_device = FlexibleModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        to_field_name='name',
-        help_text='Name or ID of device',
-        error_messages={
-            'invalid_choice': 'Child device not found.',
-        }
-    )
-
-    class Meta:
-        model = DeviceBay
-        fields = DeviceBay.csv_headers
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit installed device choices to devices of the correct type and location
-        if self.is_bound:
-            try:
-                device = self.fields['device'].to_python(self.data['device'])
-            except forms.ValidationError:
-                device = None
-        else:
-            try:
-                device = self.instance.device
-            except Device.DoesNotExist:
-                device = None
-
-        if device:
-            self.fields['installed_device'].queryset = Device.objects.filter(
-                site=device.site,
-                rack=device.rack,
-                parent_bay__isnull=True,
-                device_type__u_height=0,
-                device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
-            ).exclude(pk=device.pk)
-        else:
-            self.fields['installed_device'].queryset = Interface.objects.none()
-
-
-class DeviceBayBulkRenameForm(BulkRenameForm):
-    pk = forms.ModelMultipleChoiceField(
-        queryset=DeviceBay.objects.all(),
-        widget=forms.MultipleHiddenInput()
-    )
-
-
 #
 #
 # Connections
 # Connections
 #
 #

+ 1 - 1
netbox/dcim/urls.py

@@ -278,7 +278,7 @@ urlpatterns = [
     path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
     path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
     path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
     path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
     path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
     path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
-    # path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
+    path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
 
 
     # Device bays
     # Device bays
     path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
     path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),

+ 30 - 6
netbox/dcim/views.py

@@ -1930,7 +1930,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV
     permission_required = 'dcim.add_consoleport'
     permission_required = 'dcim.add_consoleport'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.ConsolePortBulkCreateForm
     model = ConsolePort
     model = ConsolePort
     model_form = forms.ConsolePortForm
     model_form = forms.ConsolePortForm
     filterset = filters.DeviceFilterSet
     filterset = filters.DeviceFilterSet
@@ -1942,7 +1942,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC
     permission_required = 'dcim.add_consoleserverport'
     permission_required = 'dcim.add_consoleserverport'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.ConsoleServerPortBulkCreateForm
     model = ConsoleServerPort
     model = ConsoleServerPort
     model_form = forms.ConsoleServerPortForm
     model_form = forms.ConsoleServerPortForm
     filterset = filters.DeviceFilterSet
     filterset = filters.DeviceFilterSet
@@ -1954,7 +1954,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie
     permission_required = 'dcim.add_powerport'
     permission_required = 'dcim.add_powerport'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.PowerPortBulkCreateForm
     model = PowerPort
     model = PowerPort
     model_form = forms.PowerPortForm
     model_form = forms.PowerPortForm
     filterset = filters.DeviceFilterSet
     filterset = filters.DeviceFilterSet
@@ -1966,7 +1966,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV
     permission_required = 'dcim.add_poweroutlet'
     permission_required = 'dcim.add_poweroutlet'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.PowerOutletBulkCreateForm
     model = PowerOutlet
     model = PowerOutlet
     model_form = forms.PowerOutletForm
     model_form = forms.PowerOutletForm
     filterset = filters.DeviceFilterSet
     filterset = filters.DeviceFilterSet
@@ -1978,7 +1978,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
     permission_required = 'dcim.add_interface'
     permission_required = 'dcim.add_interface'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
-    form = forms.DeviceBulkAddInterfaceForm
+    form = forms.InterfaceBulkCreateForm
     model = Interface
     model = Interface
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
     filterset = filters.DeviceFilterSet
     filterset = filters.DeviceFilterSet
@@ -1986,11 +1986,35 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
 
 
+# class DeviceBulkAddFrontPortView(PermissionRequiredMixin, BulkComponentCreateView):
+#     permission_required = 'dcim.add_frontport'
+#     parent_model = Device
+#     parent_field = 'device'
+#     form = forms.FrontPortBulkCreateForm
+#     model = FrontPort
+#     model_form = forms.FrontPortForm
+#     filterset = filters.DeviceFilterSet
+#     table = tables.DeviceTable
+#     default_return_url = 'dcim:device_list'
+
+
+class DeviceBulkAddRearPortView(PermissionRequiredMixin, BulkComponentCreateView):
+    permission_required = 'dcim.add_rearport'
+    parent_model = Device
+    parent_field = 'device'
+    form = forms.RearPortBulkCreateForm
+    model = RearPort
+    model_form = forms.RearPortForm
+    filterset = filters.DeviceFilterSet
+    table = tables.DeviceTable
+    default_return_url = 'dcim:device_list'
+
+
 class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
 class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
     permission_required = 'dcim.add_devicebay'
     permission_required = 'dcim.add_devicebay'
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'
-    form = forms.DeviceBulkAddComponentForm
+    form = forms.DeviceBayBulkCreateForm
     model = DeviceBay
     model = DeviceBay
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
     filterset = filters.DeviceFilterSet
     filterset = filters.DeviceFilterSet

+ 1 - 0
netbox/templates/dcim/device_list.html

@@ -12,6 +12,7 @@
                 {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
                 {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
                 {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
                 {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
                 {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
                 {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
+                {% if perms.dcim.add_rearport %}<li><a href="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Rear Ports</a></li>{% endif %}
                 {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
                 {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
             </ul>
             </ul>
         </div>
         </div>

+ 14 - 0
netbox/utilities/forms.py

@@ -10,6 +10,7 @@ from django.conf import settings
 from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
 from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
 from django.db.models import Count
 from django.db.models import Count
 from django.forms import BoundField
 from django.forms import BoundField
+from django.forms.models import fields_for_model
 from django.urls import reverse
 from django.urls import reverse
 
 
 from .choices import unpack_grouped_choices
 from .choices import unpack_grouped_choices
@@ -123,6 +124,19 @@ def add_blank_choice(choices):
     return ((None, '---------'),) + tuple(choices)
     return ((None, '---------'),) + tuple(choices)
 
 
 
 
+def form_from_model(model, fields):
+    """
+    Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used
+    for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields
+    are marked as not required.
+    """
+    form_fields = fields_for_model(model, fields=fields)
+    for field in form_fields.values():
+        field.required = False
+
+    return type('FormFromModel', (forms.Form,), form_fields)
+
+
 #
 #
 # Widgets
 # Widgets
 #
 #

+ 24 - 17
netbox/utilities/views.py

@@ -972,25 +972,32 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
                 new_components = []
                 new_components = []
                 data = deepcopy(form.cleaned_data)
                 data = deepcopy(form.cleaned_data)
 
 
-                for obj in data['pk']:
-
-                    names = data['name_pattern']
-                    for name in names:
-                        component_data = {
-                            self.parent_field: obj.pk,
-                            'name': name,
-                        }
-                        component_data.update(data)
-                        component_form = self.model_form(component_data)
-                        if component_form.is_valid():
-                            new_components.append(component_form.save(commit=False))
-                        else:
-                            for field, errors in component_form.errors.as_data().items():
-                                for e in errors:
-                                    form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+                try:
+                    with transaction.atomic():
+
+                        for obj in data['pk']:
+
+                            names = data['name_pattern']
+                            for name in names:
+                                component_data = {
+                                    self.parent_field: obj.pk,
+                                    'name': name,
+                                }
+                                component_data.update(data)
+                                component_form = self.model_form(component_data)
+                                if component_form.is_valid():
+                                    instance = component_form.save()
+                                    logger.debug(f"Created {instance} on {instance.parent}")
+                                    new_components.append(instance)
+                                else:
+                                    for field, errors in component_form.errors.as_data().items():
+                                        for e in errors:
+                                            form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+
+                except IntegrityError:
+                    pass
 
 
                 if not form.errors:
                 if not form.errors:
-                    self.model.objects.bulk_create(new_components)
                     msg = "Added {} {} to {} {}.".format(
                     msg = "Added {} {} to {} {}.".format(
                         len(new_components),
                         len(new_components),
                         model_name,
                         model_name,

+ 11 - 16
netbox/virtualization/forms.py

@@ -15,7 +15,8 @@ from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
     CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
-    ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple,
+    TagFilterField,
 )
 )
 from .choices import *
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -827,24 +828,18 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
         label='Name'
         label='Name'
     )
     )
 
 
+    def clean_tags(self):
+        # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
+        # must first convert the list of tags to a string.
+        return ','.join(self.cleaned_data.get('tags'))
 
 
-class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm):
+
+class InterfaceBulkCreateForm(
+    form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
+    VirtualMachineBulkAddComponentForm
+):
     type = forms.ChoiceField(
     type = forms.ChoiceField(
         choices=VMInterfaceTypeChoices,
         choices=VMInterfaceTypeChoices,
         initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
         initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
         widget=forms.HiddenInput()
         widget=forms.HiddenInput()
     )
     )
-    enabled = forms.BooleanField(
-        required=False,
-        initial=True
-    )
-    mtu = forms.IntegerField(
-        required=False,
-        min_value=INTERFACE_MTU_MIN,
-        max_value=INTERFACE_MTU_MAX,
-        label='MTU'
-    )
-    description = forms.CharField(
-        max_length=100,
-        required=False
-    )

+ 1 - 1
netbox/virtualization/views.py

@@ -366,7 +366,7 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC
     permission_required = 'dcim.add_interface'
     permission_required = 'dcim.add_interface'
     parent_model = VirtualMachine
     parent_model = VirtualMachine
     parent_field = 'virtual_machine'
     parent_field = 'virtual_machine'
-    form = forms.VirtualMachineBulkAddInterfaceForm
+    form = forms.InterfaceBulkCreateForm
     model = Interface
     model = Interface
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
     filterset = filters.VirtualMachineFilterSet
     filterset = filters.VirtualMachineFilterSet