Przeglądaj źródła

Merge branch 'develop' into feature

jeremystretch 3 lat temu
rodzic
commit
1eb0e5d307
44 zmienionych plików z 626 dodań i 686 usunięć
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 12 1
      docs/release-notes/version-3.3.md
  4. 1 1
      netbox/dcim/api/serializers.py
  5. 13 12
      netbox/dcim/forms/bulk_create.py
  6. 146 101
      netbox/dcim/forms/models.py
  7. 178 80
      netbox/dcim/forms/object_create.py
  8. 13 11
      netbox/dcim/models/device_components.py
  9. 12 3
      netbox/dcim/tables/devices.py
  10. 1 1
      netbox/dcim/tables/template_code.py
  11. 1 0
      netbox/dcim/tests/test_api.py
  12. 10 8
      netbox/dcim/tests/test_forms.py
  13. 92 109
      netbox/dcim/tests/test_views.py
  14. 15 82
      netbox/dcim/views.py
  15. 4 4
      netbox/extras/api/views.py
  16. 2 2
      netbox/extras/management/commands/runreport.py
  17. 17 10
      netbox/extras/reports.py
  18. 7 1
      netbox/extras/scripts.py
  19. 3 3
      netbox/extras/urls.py
  20. 4 3
      netbox/extras/views.py
  21. 1 1
      netbox/netbox/settings.py
  22. 2 1
      netbox/netbox/tables/columns.py
  23. 9 8
      netbox/netbox/views/generic/bulk_views.py
  24. 12 20
      netbox/netbox/views/generic/object_views.py
  25. 0 38
      netbox/templates/dcim/component_template_create.html
  26. 0 16
      netbox/templates/dcim/device_component_edit.html
  27. 0 7
      netbox/templates/dcim/frontporttemplate_create.html
  28. 0 17
      netbox/templates/dcim/inventoryitem_create.html
  29. 0 17
      netbox/templates/dcim/inventoryitemtemplate_create.html
  30. 0 7
      netbox/templates/dcim/modulebaytemplate_create.html
  31. 1 1
      netbox/templates/extras/script_list.html
  32. 5 3
      netbox/templates/generic/object_edit.html
  33. 8 0
      netbox/templates/ipam/l2vpntermination_edit.html
  34. 10 10
      netbox/templates/login.html
  35. 0 69
      netbox/templates/virtualization/vminterface_edit.html
  36. 15 12
      netbox/users/views.py
  37. 1 1
      netbox/utilities/forms/fields/expandable.py
  38. 3 2
      netbox/utilities/testing/views.py
  39. 2 2
      netbox/virtualization/forms/bulk_create.py
  40. 10 2
      netbox/virtualization/forms/models.py
  41. 8 11
      netbox/virtualization/forms/object_create.py
  42. 4 3
      netbox/virtualization/tests/test_views.py
  43. 0 2
      netbox/virtualization/views.py
  44. 2 2
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.3.2
+      placeholder: v3.3.3
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.3.2
+      placeholder: v3.3.3
     validations:
       required: true
   - type: dropdown

+ 12 - 1
docs/release-notes/version-3.3.md

@@ -1,24 +1,35 @@
 # NetBox v3.3
 
-## v3.3.3 (FUTURE)
+## v3.3.4 (FUTURE)
+
+---
+
+## v3.3.3 (2022-09-15)
 
 ### Enhancements
 
 * [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected`
 * [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types
 * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI
+* [#10359](https://github.com/netbox-community/netbox/issues/10359) - Add region and site group columns to the devices table
 
 ### Bug Fixes
 
 * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters
+* [#10247](https://github.com/netbox-community/netbox/issues/10247) - Allow changing the pre-populated device/VM when creating new components
 * [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables
+* [#10258](https://github.com/netbox-community/netbox/issues/10258) - Enable the use of reports & scripts packaged in submodules
 * [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed
 * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services
 * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments
 * [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field
 * [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links
+* [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API
 * [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection
 * [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import
+* [#10337](https://github.com/netbox-community/netbox/issues/10337) - Display SSO links when local authentication fails
+* [#10353](https://github.com/netbox-community/netbox/issues/10353) - Table action buttons should reserve return URL parameters
+* [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination
 
 ---
 

+ 1 - 1
netbox/dcim/api/serializers.py

@@ -1076,7 +1076,7 @@ class CablePathSerializer(serializers.ModelSerializer):
 
 class VirtualChassisSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
-    master = NestedDeviceSerializer(required=False)
+    master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
     member_count = serializers.IntegerField(read_only=True)
 
     class Meta:

+ 13 - 12
netbox/dcim/forms/bulk_create.py

@@ -3,7 +3,7 @@ from django import forms
 from dcim.models import *
 from extras.forms import CustomFieldsMixin
 from extras.models import Tag
-from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
+from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
 from .object_create import ComponentCreateForm
 
 __all__ = (
@@ -24,7 +24,7 @@ __all__ = (
 # Device components
 #
 
-class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
+class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCreateForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Device.objects.all(),
         widget=forms.MultipleHiddenInput()
@@ -37,6 +37,7 @@ class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
         queryset=Tag.objects.all(),
         required=False
     )
+    replication_fields = ('name', 'label')
 
 
 class ConsolePortBulkCreateForm(
@@ -44,7 +45,7 @@ class ConsolePortBulkCreateForm(
     DeviceBulkAddComponentForm
 ):
     model = ConsolePort
-    field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags')
+    field_order = ('name', 'label', 'type', 'mark_connected', 'description', 'tags')
 
 
 class ConsoleServerPortBulkCreateForm(
@@ -52,7 +53,7 @@ class ConsoleServerPortBulkCreateForm(
     DeviceBulkAddComponentForm
 ):
     model = ConsoleServerPort
-    field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags')
+    field_order = ('name', 'label', 'type', 'speed', 'description', 'tags')
 
 
 class PowerPortBulkCreateForm(
@@ -60,7 +61,7 @@ class PowerPortBulkCreateForm(
     DeviceBulkAddComponentForm
 ):
     model = PowerPort
-    field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
+    field_order = ('name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
 
 
 class PowerOutletBulkCreateForm(
@@ -68,7 +69,7 @@ class PowerOutletBulkCreateForm(
     DeviceBulkAddComponentForm
 ):
     model = PowerOutlet
-    field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags')
+    field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags')
 
 
 class InterfaceBulkCreateForm(
@@ -79,7 +80,7 @@ class InterfaceBulkCreateForm(
 ):
     model = Interface
     field_order = (
-        'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
+        'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
         'poe_type', 'mark_connected', 'description', 'tags',
     )
 
@@ -96,13 +97,13 @@ class RearPortBulkCreateForm(
     DeviceBulkAddComponentForm
 ):
     model = RearPort
-    field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
+    field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags')
 
 
 class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
     model = ModuleBay
-    field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags')
-
+    field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
+    replication_fields = ('name', 'label', 'position')
     position_pattern = ExpandableNameField(
         label='Position',
         required=False,
@@ -112,7 +113,7 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
 
 class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
     model = DeviceBay
-    field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
+    field_order = ('name', 'label', 'description', 'tags')
 
 
 class InventoryItemBulkCreateForm(
@@ -121,6 +122,6 @@ class InventoryItemBulkCreateForm(
 ):
     model = InventoryItem
     field_order = (
-        'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
+        'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
         'description', 'tags',
     )

+ 146 - 101
netbox/dcim/forms/models.py

@@ -986,47 +986,74 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
 # Device component templates
 #
 
+class ComponentTemplateForm(BootstrapMixin, forms.ModelForm):
+    device_type = DynamicModelChoiceField(
+        queryset=DeviceType.objects.all()
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Disable reassignment of DeviceType when editing an existing instance
+        if self.instance.pk:
+            self.fields['device_type'].disabled = True
+
+
+class ModularComponentTemplateForm(ComponentTemplateForm):
+    module_type = DynamicModelChoiceField(
+        queryset=ModuleType.objects.all(),
+        required=False
+    )
+
+
+class ConsolePortTemplateForm(ModularComponentTemplateForm):
+    fieldsets = (
+        (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
+    )
 
-class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ConsolePortTemplate
         fields = [
             'device_type', 'module_type', 'name', 'label', 'type', 'description',
         ]
         widgets = {
-            'device_type': forms.HiddenInput(),
-            'module_type': forms.HiddenInput(),
             'type': StaticSelect,
         }
 
 
-class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
+class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
+    fieldsets = (
+        (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
+    )
+
     class Meta:
         model = ConsoleServerPortTemplate
         fields = [
             'device_type', 'module_type', 'name', 'label', 'type', 'description',
         ]
         widgets = {
-            'device_type': forms.HiddenInput(),
-            'module_type': forms.HiddenInput(),
             'type': StaticSelect,
         }
 
 
-class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
+class PowerPortTemplateForm(ModularComponentTemplateForm):
+    fieldsets = (
+        (None, (
+            'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+        )),
+    )
+
     class Meta:
         model = PowerPortTemplate
         fields = [
             'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
         ]
         widgets = {
-            'device_type': forms.HiddenInput(),
-            'module_type': forms.HiddenInput(),
             'type': StaticSelect(),
         }
 
 
-class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+class PowerOutletTemplateForm(ModularComponentTemplateForm):
     power_port = DynamicModelChoiceField(
         queryset=PowerPortTemplate.objects.all(),
         required=False,
@@ -1035,35 +1062,40 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
         }
     )
 
+    fieldsets = (
+        (None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')),
+    )
+
     class Meta:
         model = PowerOutletTemplate
         fields = [
             'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
         ]
         widgets = {
-            'device_type': forms.HiddenInput(),
-            'module_type': forms.HiddenInput(),
             'type': StaticSelect(),
             'feed_leg': StaticSelect(),
         }
 
 
-class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
+class InterfaceTemplateForm(ModularComponentTemplateForm):
+    fieldsets = (
+        (None, ('device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description')),
+        ('PoE', ('poe_mode', 'poe_type'))
+    )
+
     class Meta:
         model = InterfaceTemplate
         fields = [
             'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
         ]
         widgets = {
-            'device_type': forms.HiddenInput(),
-            'module_type': forms.HiddenInput(),
             'type': StaticSelect(),
             'poe_mode': StaticSelect(),
             'poe_type': StaticSelect(),
         }
 
 
-class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
+class FrontPortTemplateForm(ModularComponentTemplateForm):
     rear_port = DynamicModelChoiceField(
         queryset=RearPortTemplate.objects.all(),
         required=False,
@@ -1073,6 +1105,13 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
         }
     )
 
+    fieldsets = (
+        (None, (
+            'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
+            'description',
+        )),
+    )
+
     class Meta:
         model = FrontPortTemplate
         fields = [
@@ -1080,48 +1119,50 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
             'description',
         ]
         widgets = {
-            'device_type': forms.HiddenInput(),
-            'module_type': forms.HiddenInput(),
             'type': StaticSelect(),
         }
 
 
-class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
+class RearPortTemplateForm(ModularComponentTemplateForm):
+    fieldsets = (
+        (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')),
+    )
+
     class Meta:
         model = RearPortTemplate
         fields = [
             'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
         ]
         widgets = {
-            'device_type': forms.HiddenInput(),
-            'module_type': forms.HiddenInput(),
             'type': StaticSelect(),
         }
 
 
-class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm):
+class ModuleBayTemplateForm(ComponentTemplateForm):
+    fieldsets = (
+        (None, ('device_type', 'name', 'label', 'position', 'description')),
+    )
+
     class Meta:
         model = ModuleBayTemplate
         fields = [
             'device_type', 'name', 'label', 'position', 'description',
         ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-        }
 
 
-class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
+class DeviceBayTemplateForm(ComponentTemplateForm):
+    fieldsets = (
+        (None, ('device_type', 'name', 'label', 'description')),
+    )
+
     class Meta:
         model = DeviceBayTemplate
         fields = [
             'device_type', 'name', 'label', 'description',
         ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-        }
 
 
-class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
+class InventoryItemTemplateForm(ComponentTemplateForm):
     parent = DynamicModelChoiceField(
         queryset=InventoryItemTemplate.objects.all(),
         required=False,
@@ -1148,22 +1189,39 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
         widget=forms.HiddenInput
     )
 
+    fieldsets = (
+        (None, (
+            'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
+            'component_type', 'component_id',
+        )),
+    )
+
     class Meta:
         model = InventoryItemTemplate
         fields = [
             'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
             'component_type', 'component_id',
         ]
-        widgets = {
-            'device_type': forms.HiddenInput(),
-        }
 
 
 #
 # Device components
 #
 
-class ConsolePortForm(NetBoxModelForm):
+class DeviceComponentForm(NetBoxModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all()
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Disable reassignment of Device when editing an existing instance
+        if self.instance.pk:
+            self.fields['device'].disabled = True
+
+
+class ModularDeviceComponentForm(DeviceComponentForm):
     module = DynamicModelChoiceField(
         queryset=Module.objects.all(),
         required=False,
@@ -1172,25 +1230,31 @@ class ConsolePortForm(NetBoxModelForm):
         }
     )
 
+
+class ConsolePortForm(ModularDeviceComponentForm):
+    fieldsets = (
+        (None, (
+            'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = ConsolePort
         fields = [
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
         ]
         widgets = {
-            'device': forms.HiddenInput(),
             'type': StaticSelect(),
             'speed': StaticSelect(),
         }
 
 
-class ConsoleServerPortForm(NetBoxModelForm):
-    module = DynamicModelChoiceField(
-        queryset=Module.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
+class ConsoleServerPortForm(ModularDeviceComponentForm):
+
+    fieldsets = (
+        (None, (
+            'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
+        )),
     )
 
     class Meta:
@@ -1199,42 +1263,32 @@ class ConsoleServerPortForm(NetBoxModelForm):
             'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
         ]
         widgets = {
-            'device': forms.HiddenInput(),
             'type': StaticSelect(),
             'speed': StaticSelect(),
         }
 
 
-class PowerPortForm(NetBoxModelForm):
-    module = DynamicModelChoiceField(
-        queryset=Module.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
+class PowerPortForm(ModularDeviceComponentForm):
+
+    fieldsets = (
+        (None, (
+            'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
+            'description', 'tags',
+        )),
     )
 
     class Meta:
         model = PowerPort
         fields = [
             'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
-            'description',
-            'tags',
+            'description', 'tags',
         ]
         widgets = {
-            'device': forms.HiddenInput(),
             'type': StaticSelect(),
         }
 
 
-class PowerOutletForm(NetBoxModelForm):
-    module = DynamicModelChoiceField(
-        queryset=Module.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
-    )
+class PowerOutletForm(ModularDeviceComponentForm):
     power_port = DynamicModelChoiceField(
         queryset=PowerPort.objects.all(),
         required=False,
@@ -1243,6 +1297,13 @@ class PowerOutletForm(NetBoxModelForm):
         }
     )
 
+    fieldsets = (
+        (None, (
+            'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
+            'tags',
+        )),
+    )
+
     class Meta:
         model = PowerOutlet
         fields = [
@@ -1250,20 +1311,12 @@ class PowerOutletForm(NetBoxModelForm):
             'tags',
         ]
         widgets = {
-            'device': forms.HiddenInput(),
             'type': StaticSelect(),
             'feed_leg': StaticSelect(),
         }
 
 
-class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
-    module = DynamicModelChoiceField(
-        queryset=Module.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
-    )
+class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     parent = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         required=False,
@@ -1338,7 +1391,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
     )
 
     fieldsets = (
-        ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
+        ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
         ('Addressing', ('vrf', 'mac_address', 'wwn')),
         ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
         ('Related Interfaces', ('parent', 'bridge', 'lag')),
@@ -1358,7 +1411,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
             'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
         ]
         widgets = {
-            'device': forms.HiddenInput(),
             'type': StaticSelect(),
             'speed': SelectSpeedWidget(),
             'poe_mode': StaticSelect(),
@@ -1388,14 +1440,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
             self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
 
 
-class FrontPortForm(NetBoxModelForm):
-    module = DynamicModelChoiceField(
-        queryset=Module.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
-    )
+class FrontPortForm(ModularDeviceComponentForm):
     rear_port = DynamicModelChoiceField(
         queryset=RearPort.objects.all(),
         query_params={
@@ -1403,6 +1448,13 @@ class FrontPortForm(NetBoxModelForm):
         }
     )
 
+    fieldsets = (
+        (None, (
+            'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
+            'description', 'tags',
+        )),
+    )
+
     class Meta:
         model = FrontPort
         fields = [
@@ -1410,18 +1462,15 @@ class FrontPortForm(NetBoxModelForm):
             'description', 'tags',
         ]
         widgets = {
-            'device': forms.HiddenInput(),
             'type': StaticSelect(),
         }
 
 
-class RearPortForm(NetBoxModelForm):
-    module = DynamicModelChoiceField(
-        queryset=Module.objects.all(),
-        required=False,
-        query_params={
-            'device_id': '$device',
-        }
+class RearPortForm(ModularDeviceComponentForm):
+    fieldsets = (
+        (None, (
+            'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
+        )),
     )
 
     class Meta:
@@ -1430,33 +1479,32 @@ class RearPortForm(NetBoxModelForm):
             'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
         ]
         widgets = {
-            'device': forms.HiddenInput(),
             'type': StaticSelect(),
         }
 
 
-class ModuleBayForm(NetBoxModelForm):
+class ModuleBayForm(DeviceComponentForm):
+    fieldsets = (
+        (None, ('device', 'name', 'label', 'position', 'description', 'tags',)),
+    )
 
     class Meta:
         model = ModuleBay
         fields = [
             'device', 'name', 'label', 'position', 'description', 'tags',
         ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
 
 
-class DeviceBayForm(NetBoxModelForm):
+class DeviceBayForm(DeviceComponentForm):
+    fieldsets = (
+        (None, ('device', 'name', 'label', 'description', 'tags',)),
+    )
 
     class Meta:
         model = DeviceBay
         fields = [
             'device', 'name', 'label', 'description', 'tags',
         ]
-        widgets = {
-            'device': forms.HiddenInput(),
-        }
 
 
 class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
@@ -1479,10 +1527,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
         ).exclude(pk=device_bay.device.pk)
 
 
-class InventoryItemForm(NetBoxModelForm):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all()
-    )
+class InventoryItemForm(DeviceComponentForm):
     parent = DynamicModelChoiceField(
         queryset=InventoryItem.objects.all(),
         required=False,

+ 178 - 80
netbox/dcim/forms/object_create.py

@@ -2,46 +2,56 @@ from django import forms
 
 from dcim.models import *
 from netbox.forms import NetBoxModelForm
-from utilities.forms import (
-    BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
-)
+from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
+from . import models as model_forms
 
 __all__ = (
-    'ComponentTemplateCreateForm',
-    'DeviceComponentCreateForm',
+    'ComponentCreateForm',
+    'ConsolePortCreateForm',
+    'ConsolePortTemplateCreateForm',
+    'ConsoleServerPortCreateForm',
+    'ConsoleServerPortTemplateCreateForm',
+    'DeviceBayCreateForm',
+    'DeviceBayTemplateCreateForm',
     'FrontPortCreateForm',
     'FrontPortTemplateCreateForm',
+    'InterfaceCreateForm',
+    'InterfaceTemplateCreateForm',
     'InventoryItemCreateForm',
-    'ModularComponentTemplateCreateForm',
+    'InventoryItemTemplateCreateForm',
     'ModuleBayCreateForm',
     'ModuleBayTemplateCreateForm',
+    'PowerOutletCreateForm',
+    'PowerOutletTemplateCreateForm',
+    'PowerPortCreateForm',
+    'PowerPortTemplateCreateForm',
+    'RearPortCreateForm',
+    'RearPortTemplateCreateForm',
     'VirtualChassisCreateForm',
 )
 
 
-class ComponentCreateForm(BootstrapMixin, forms.Form):
+class ComponentCreateForm(forms.Form):
     """
-    Subclass this form when facilitating the creation of one or more device component or component templates based on
+    Subclass this form when facilitating the creation of one or more component or component template objects based on
     a name pattern.
     """
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
-    label_pattern = ExpandableNameField(
-        label='Label',
+    name = ExpandableNameField()
+    label = ExpandableNameField(
         required=False,
-        help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
+        help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
     )
 
+    # Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by
+    # ComponentCreateView when creating objects.
+    replication_fields = ('name', 'label')
+
     def clean(self):
         super().clean()
 
-        # Validate that all patterned fields generate an equal number of values
-        patterned_fields = [
-            field_name for field_name in self.fields if field_name.endswith('_pattern')
-        ]
-        pattern_count = len(self.cleaned_data['name_pattern'])
-        for field_name in patterned_fields:
+        # Validate that all replication fields generate an equal number of values
+        pattern_count = len(self.cleaned_data[self.replication_fields[0]])
+        for field_name in self.replication_fields:
             value_count = len(self.cleaned_data[field_name])
             if self.cleaned_data[field_name] and value_count != pattern_count:
                 raise forms.ValidationError({
@@ -50,56 +60,55 @@ class ComponentCreateForm(BootstrapMixin, forms.Form):
                 }, code='label_pattern_mismatch')
 
 
-class ComponentTemplateCreateForm(ComponentCreateForm):
-    """
-    Creation form for component templates that can be assigned only to a DeviceType.
-    """
-    device_type = DynamicModelChoiceField(
-        queryset=DeviceType.objects.all(),
-    )
-    field_order = ('device_type', 'name_pattern', 'label_pattern')
+#
+# Device component templates
+#
 
+class ConsolePortTemplateCreateForm(ComponentCreateForm, model_forms.ConsolePortTemplateForm):
 
-class ModularComponentTemplateCreateForm(ComponentCreateForm):
-    """
-    Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
-    """
-    name_pattern = ExpandableNameField(
-        label='Name',
-        help_text="""
-                Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
-                are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>.  {module} is accepted as a substitution for
-                the module bay position.
-                """
-    )
-    device_type = DynamicModelChoiceField(
-        queryset=DeviceType.objects.all(),
-        required=False
-    )
-    module_type = DynamicModelChoiceField(
-        queryset=ModuleType.objects.all(),
-        required=False
-    )
-    field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern')
+    class Meta(model_forms.ConsolePortTemplateForm.Meta):
+        exclude = ('name', 'label')
 
 
-class DeviceComponentCreateForm(ComponentCreateForm):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all()
-    )
-    field_order = ('device', 'name_pattern', 'label_pattern')
+class ConsoleServerPortTemplateCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortTemplateForm):
+
+    class Meta(model_forms.ConsoleServerPortTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
+class PowerPortTemplateCreateForm(ComponentCreateForm, model_forms.PowerPortTemplateForm):
+
+    class Meta(model_forms.PowerPortTemplateForm.Meta):
+        exclude = ('name', 'label')
+
 
+class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutletTemplateForm):
 
-class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
-    rear_port_set = forms.MultipleChoiceField(
+    class Meta(model_forms.PowerOutletTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
+class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
+
+    class Meta(model_forms.InterfaceTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
+class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
+    rear_port = forms.MultipleChoiceField(
         choices=[],
         label='Rear ports',
         help_text='Select one rear port assignment for each front port being created.',
     )
-    field_order = (
-        'device_type', 'name_pattern', 'label_pattern', 'rear_port_set',
+
+    # Override fieldsets from FrontPortTemplateForm to omit rear_port_position
+    fieldsets = (
+        (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')),
     )
 
+    class Meta(model_forms.FrontPortTemplateForm.Meta):
+        exclude = ('name', 'label', 'rear_port', 'rear_port_position')
+
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
@@ -130,12 +139,12 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
                     choices.append(
                         ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
                     )
-        self.fields['rear_port_set'].choices = choices
+        self.fields['rear_port'].choices = choices
 
     def get_iterative_data(self, iteration):
 
         # Assign rear port and position from selected set
-        rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+        rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
 
         return {
             'rear_port': int(rear_port),
@@ -143,16 +152,94 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
         }
 
 
-class FrontPortCreateForm(DeviceComponentCreateForm):
-    rear_port_set = forms.MultipleChoiceField(
+class RearPortTemplateCreateForm(ComponentCreateForm, model_forms.RearPortTemplateForm):
+
+    class Meta(model_forms.RearPortTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
+class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemplateForm):
+
+    class Meta(model_forms.DeviceBayTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
+class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm):
+    position = ExpandableNameField(
+        label='Position',
+        required=False,
+        help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
+    )
+    replication_fields = ('name', 'label', 'position')
+
+    class Meta(model_forms.ModuleBayTemplateForm.Meta):
+        exclude = ('name', 'label', 'position')
+
+
+class InventoryItemTemplateCreateForm(ComponentCreateForm, model_forms.InventoryItemTemplateForm):
+
+    class Meta(model_forms.InventoryItemTemplateForm.Meta):
+        exclude = ('name', 'label')
+
+
+#
+# Device components
+#
+
+class ConsolePortCreateForm(ComponentCreateForm, model_forms.ConsolePortForm):
+
+    class Meta(model_forms.ConsolePortForm.Meta):
+        exclude = ('name', 'label')
+
+
+class ConsoleServerPortCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortForm):
+
+    class Meta(model_forms.ConsoleServerPortForm.Meta):
+        exclude = ('name', 'label')
+
+
+class PowerPortCreateForm(ComponentCreateForm, model_forms.PowerPortForm):
+
+    class Meta(model_forms.PowerPortForm.Meta):
+        exclude = ('name', 'label')
+
+
+class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
+
+    class Meta(model_forms.PowerOutletForm.Meta):
+        exclude = ('name', 'label')
+
+
+class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
+
+    class Meta(model_forms.InterfaceForm.Meta):
+        exclude = ('name', 'label')
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if 'module' in self.fields:
+            self.fields['name'].help_text += ' The string <code>{module}</code> will be replaced with the position ' \
+                                             'of the assigned module, if any'
+
+
+class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
+    rear_port = forms.MultipleChoiceField(
         choices=[],
         label='Rear ports',
         help_text='Select one rear port assignment for each front port being created.',
     )
-    field_order = (
-        'device', 'name_pattern', 'label_pattern', 'rear_port_set',
+
+    # Override fieldsets from FrontPortForm to omit rear_port_position
+    fieldsets = (
+        (None, (
+            'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
+        )),
     )
 
+    class Meta(model_forms.FrontPortForm.Meta):
+        exclude = ('name', 'label', 'rear_port', 'rear_port_position')
+
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
@@ -176,12 +263,12 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
                     choices.append(
                         ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
                     )
-        self.fields['rear_port_set'].choices = choices
+        self.fields['rear_port'].choices = choices
 
     def get_iterative_data(self, iteration):
 
         # Assign rear port and position from selected set
-        rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+        rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
 
         return {
             'rear_port': int(rear_port),
@@ -189,28 +276,39 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
         }
 
 
-class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
-    position_pattern = ExpandableNameField(
-        label='Position',
-        required=False,
-        help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
-    )
-    field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern')
+class RearPortCreateForm(ComponentCreateForm, model_forms.RearPortForm):
+
+    class Meta(model_forms.RearPortForm.Meta):
+        exclude = ('name', 'label')
+
+
+class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm):
 
+    class Meta(model_forms.DeviceBayForm.Meta):
+        exclude = ('name', 'label')
 
-class ModuleBayCreateForm(DeviceComponentCreateForm):
-    position_pattern = ExpandableNameField(
+
+class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm):
+    position = ExpandableNameField(
         label='Position',
         required=False,
-        help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
+        help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
     )
-    field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern')
+    replication_fields = ('name', 'label', 'position')
+
+    class Meta(model_forms.ModuleBayForm.Meta):
+        exclude = ('name', 'label', 'position')
+
+
+class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm):
 
+    class Meta(model_forms.InventoryItemForm.Meta):
+        exclude = ('name', 'label')
 
-class InventoryItemCreateForm(ComponentCreateForm):
-    # Device is assigned by the model form
-    field_order = ('name_pattern', 'label_pattern')
 
+#
+# Virtual chassis
+#
 
 class VirtualChassisCreateForm(NetBoxModelForm):
     region = DynamicModelChoiceField(

+ 13 - 11
netbox/dcim/models/device_components.py

@@ -908,18 +908,20 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
     def clean(self):
         super().clean()
 
-        # Validate rear port assignment
-        if self.rear_port.device != self.device:
-            raise ValidationError({
-                "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
-            })
+        if hasattr(self, 'rear_port'):
 
-        # Validate rear port position assignment
-        if self.rear_port_position > self.rear_port.positions:
-            raise ValidationError({
-                "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
-                                      f"{self.rear_port.name} has only {self.rear_port.positions} positions"
-            })
+            # Validate rear port assignment
+            if self.rear_port.device != self.device:
+                raise ValidationError({
+                    "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
+                })
+
+            # Validate rear port position assignment
+            if self.rear_port_position > self.rear_port.positions:
+                raise ValidationError({
+                    "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
+                                          f"{self.rear_port.name} has only {self.rear_port.positions} positions"
+                })
 
 
 class RearPort(ModularComponentModel, CabledObjectModel):

+ 12 - 3
netbox/dcim/tables/devices.py

@@ -143,6 +143,15 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
         template_code=DEVICE_LINK
     )
     status = columns.ChoiceFieldColumn()
+    region = tables.Column(
+        accessor=Accessor('site__region'),
+        linkify=True
+    )
+    site_group = tables.Column(
+        accessor=Accessor('site__group'),
+        linkify=True,
+        verbose_name='Site Group'
+    )
     site = tables.Column(
         linkify=True
     )
@@ -203,9 +212,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
         model = Device
         fields = (
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
-            'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow',
-            'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
-            'contacts', 'tags', 'created', 'last_updated',
+            'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
+            'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
+            'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = (
             'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

+ 1 - 1
netbox/dcim/tables/template_code.py

@@ -239,7 +239,7 @@ INTERFACE_BUTTONS = """
         <li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
       {% endif %}
       {% if perms.dcim.add_interface %}
-        <li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name_pattern={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
+        <li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
       {% endif %}
       {% if perms.ipam.add_l2vpntermination %}
         <li><a class="dropdown-item" href="{% url 'ipam:l2vpntermination_add' %}?device={{ object.pk }}&interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">L2VPN Termination</a></li>

+ 1 - 0
netbox/dcim/tests/test_api.py

@@ -2057,6 +2057,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
 
         cls.bulk_update_data = {
             'domain': 'newdomain',
+            'master': None
         }
 
 

+ 10 - 8
netbox/dcim/tests/test_forms.py

@@ -1,6 +1,6 @@
 from django.test import TestCase
 
-from dcim.choices import DeviceFaceChoices, DeviceStatusChoices
+from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
 from dcim.forms import *
 from dcim.models import *
 from utilities.testing import create_test_device
@@ -129,10 +129,11 @@ class LabelTestCase(TestCase):
         """
         interface_data = {
             'device': self.device.pk,
-            'name_pattern': 'eth[0-9]',
-            'label_pattern': 'Interface[0-9]',
+            'name': 'eth[0-9]',
+            'label': 'Interface[0-9]',
+            'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
         }
-        form = DeviceComponentCreateForm(interface_data)
+        form = InterfaceCreateForm(interface_data)
 
         self.assertTrue(form.is_valid())
 
@@ -142,10 +143,11 @@ class LabelTestCase(TestCase):
         """
         bad_interface_data = {
             'device': self.device.pk,
-            'name_pattern': 'eth[0-9]',
-            'label_pattern': 'Interface[0-1]',
+            'name': 'eth[0-9]',
+            'label': 'Interface[0-1]',
+            'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
         }
-        form = DeviceComponentCreateForm(bad_interface_data)
+        form = InterfaceCreateForm(bad_interface_data)
 
         self.assertFalse(form.is_valid())
-        self.assertIn('label_pattern', form.errors)
+        self.assertIn('label', form.errors)

+ 92 - 109
netbox/dcim/tests/test_views.py

@@ -1082,31 +1082,28 @@ front-ports:
 
 class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsolePortTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        devicetypes = (
-            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
-        )
-        DeviceType.objects.bulk_create(devicetypes)
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
 
         ConsolePortTemplate.objects.bulk_create((
-            ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'),
-            ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'),
-            ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'),
+            ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
+            ConsolePortTemplate(device_type=devicetype, name='Console Port Template 2'),
+            ConsolePortTemplate(device_type=devicetype, name='Console Port Template 3'),
         ))
 
         cls.form_data = {
-            'device_type': devicetypes[1].pk,
+            'device_type': devicetype.pk,
             'name': 'Console Port Template X',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
         }
 
         cls.bulk_create_data = {
-            'device_type': devicetypes[1].pk,
-            'name_pattern': 'Console Port Template [4-6]',
+            'device_type': devicetype.pk,
+            'name': 'Console Port Template [4-6]',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
         }
 
@@ -1117,31 +1114,28 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsoleServerPortTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        devicetypes = (
-            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
-        )
-        DeviceType.objects.bulk_create(devicetypes)
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
 
         ConsoleServerPortTemplate.objects.bulk_create((
-            ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'),
-            ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'),
-            ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'),
+            ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
+            ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 2'),
+            ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 3'),
         ))
 
         cls.form_data = {
-            'device_type': devicetypes[1].pk,
+            'device_type': devicetype.pk,
             'name': 'Console Server Port Template X',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
         }
 
         cls.bulk_create_data = {
-            'device_type': devicetypes[1].pk,
-            'name_pattern': 'Console Server Port Template [4-6]',
+            'device_type': devicetype.pk,
+            'name': 'Console Server Port Template [4-6]',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
         }
 
@@ -1152,24 +1146,21 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie
 
 class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerPortTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        devicetypes = (
-            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
-        )
-        DeviceType.objects.bulk_create(devicetypes)
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
 
         PowerPortTemplate.objects.bulk_create((
-            PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'),
-            PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'),
-            PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'),
+            PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
+            PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
+            PowerPortTemplate(device_type=devicetype, name='Power Port Template 3'),
         ))
 
         cls.form_data = {
-            'device_type': devicetypes[1].pk,
+            'device_type': devicetype.pk,
             'name': 'Power Port Template X',
             'type': PowerPortTypeChoices.TYPE_IEC_C14,
             'maximum_draw': 100,
@@ -1177,8 +1168,8 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         }
 
         cls.bulk_create_data = {
-            'device_type': devicetypes[1].pk,
-            'name_pattern': 'Power Port Template [4-6]',
+            'device_type': devicetype.pk,
+            'name': 'Power Port Template [4-6]',
             'type': PowerPortTypeChoices.TYPE_IEC_C14,
             'maximum_draw': 100,
             'allocated_draw': 50,
@@ -1193,6 +1184,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerOutletTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -1220,7 +1212,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
         cls.bulk_create_data = {
             'device_type': devicetype.pk,
-            'name_pattern': 'Power Outlet Template [4-6]',
+            'name': 'Power Outlet Template [4-6]',
             'type': PowerOutletTypeChoices.TYPE_IEC_C13,
             'power_port': powerports[0].pk,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
@@ -1234,34 +1226,31 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InterfaceTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        devicetypes = (
-            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
-        )
-        DeviceType.objects.bulk_create(devicetypes)
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
 
         InterfaceTemplate.objects.bulk_create((
-            InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'),
-            InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'),
-            InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'),
+            InterfaceTemplate(device_type=devicetype, name='Interface Template 1'),
+            InterfaceTemplate(device_type=devicetype, name='Interface Template 2'),
+            InterfaceTemplate(device_type=devicetype, name='Interface Template 3'),
         ))
 
         cls.form_data = {
-            'device_type': devicetypes[1].pk,
+            'device_type': devicetype.pk,
             'name': 'Interface Template X',
             'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
             'mgmt_only': True,
         }
 
         cls.bulk_create_data = {
-            'device_type': devicetypes[1].pk,
-            'name_pattern': 'Interface Template [4-6]',
+            'device_type': devicetype.pk,
+            'name': 'Interface Template [4-6]',
             # Test that a label can be applied to each generated interface templates
-            'label_pattern': 'Interface Template Label [3-5]',
+            'label': 'Interface Template Label [3-5]',
             'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
             'mgmt_only': True,
         }
@@ -1274,6 +1263,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = FrontPortTemplate
+    validation_excluded_fields = ('name', 'label', 'rear_port')
 
     @classmethod
     def setUpTestData(cls):
@@ -1306,11 +1296,9 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
         cls.bulk_create_data = {
             'device_type': devicetype.pk,
-            'name_pattern': 'Front Port [4-6]',
+            'name': 'Front Port [4-6]',
             'type': PortTypeChoices.TYPE_8P8C,
-            'rear_port_set': [
-                '{}:1'.format(rp.pk) for rp in rearports[3:6]
-            ],
+            'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
         }
 
         cls.bulk_edit_data = {
@@ -1320,32 +1308,29 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = RearPortTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        devicetypes = (
-            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
-        )
-        DeviceType.objects.bulk_create(devicetypes)
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
 
         RearPortTemplate.objects.bulk_create((
-            RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'),
-            RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'),
-            RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
         ))
 
         cls.form_data = {
-            'device_type': devicetypes[1].pk,
+            'device_type': devicetype.pk,
             'name': 'Rear Port Template X',
             'type': PortTypeChoices.TYPE_8P8C,
             'positions': 2,
         }
 
         cls.bulk_create_data = {
-            'device_type': devicetypes[1].pk,
-            'name_pattern': 'Rear Port Template [4-6]',
+            'device_type': devicetype.pk,
+            'name': 'Rear Port Template [4-6]',
             'type': PortTypeChoices.TYPE_8P8C,
             'positions': 2,
         }
@@ -1357,30 +1342,27 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
 
 class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ModuleBayTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        devicetypes = (
-            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
-        )
-        DeviceType.objects.bulk_create(devicetypes)
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
 
         ModuleBayTemplate.objects.bulk_create((
-            ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'),
-            ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'),
-            ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'),
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'),
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'),
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'),
         ))
 
         cls.form_data = {
-            'device_type': devicetypes[1].pk,
+            'device_type': devicetype.pk,
             'name': 'Module Bay Template X',
         }
 
         cls.bulk_create_data = {
-            'device_type': devicetypes[1].pk,
-            'name_pattern': 'Module Bay Template [4-6]',
+            'device_type': devicetype.pk,
+            'name': 'Module Bay Template [4-6]',
         }
 
         cls.bulk_edit_data = {
@@ -1390,30 +1372,27 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = DeviceBayTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        devicetypes = (
-            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
-            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
-        )
-        DeviceType.objects.bulk_create(devicetypes)
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT)
 
         DeviceBayTemplate.objects.bulk_create((
-            DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'),
-            DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'),
-            DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'),
+            DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'),
+            DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'),
+            DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'),
         ))
 
         cls.form_data = {
-            'device_type': devicetypes[1].pk,
+            'device_type': devicetype.pk,
             'name': 'Device Bay Template X',
         }
 
         cls.bulk_create_data = {
-            'device_type': devicetypes[1].pk,
-            'name_pattern': 'Device Bay Template [4-6]',
+            'device_type': devicetype.pk,
+            'name': 'Device Bay Template [4-6]',
         }
 
         cls.bulk_edit_data = {
@@ -1423,6 +1402,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InventoryItemTemplate
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -1431,30 +1411,25 @@ class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
             Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
         )
         Manufacturer.objects.bulk_create(manufacturers)
-
-        devicetypes = (
-            DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
-            DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
-        )
-        DeviceType.objects.bulk_create(devicetypes)
+        devicetype = DeviceType.objects.create(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1')
 
         inventory_item_templates = (
-            InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]),
-            InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]),
-            InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]),
+            InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturers[0]),
+            InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturers[0]),
+            InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturers[0]),
         )
         for item in inventory_item_templates:
             item.save()
 
         cls.form_data = {
-            'device_type': devicetypes[1].pk,
+            'device_type': devicetype.pk,
             'name': 'Inventory Item Template X',
             'manufacturer': manufacturers[1].pk,
         }
 
         cls.bulk_create_data = {
-            'device_type': devicetypes[1].pk,
-            'name_pattern': 'Inventory Item Template [4-6]',
+            'device_type': devicetype.pk,
+            'name': 'Inventory Item Template [4-6]',
             'manufacturer': manufacturers[1].pk,
         }
 
@@ -1912,6 +1887,7 @@ class ModuleTestCase(
 
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsolePort
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -1935,9 +1911,9 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Console Port [4-6]',
+            'name': 'Console Port [4-6]',
             # Test that a label can be applied to each generated console ports
-            'label_pattern': 'Serial[3-5]',
+            'label': 'Serial[3-5]',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console port',
             'tags': sorted([t.pk for t in tags]),
@@ -1970,6 +1946,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsoleServerPort
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -1993,7 +1970,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Console Server Port [4-6]',
+            'name': 'Console Server Port [4-6]',
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console server port',
             'tags': [t.pk for t in tags],
@@ -2026,6 +2003,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerPort
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -2051,7 +2029,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Power Port [4-6]]',
+            'name': 'Power Port [4-6]]',
             'type': PowerPortTypeChoices.TYPE_IEC_C14,
             'maximum_draw': 100,
             'allocated_draw': 50,
@@ -2088,6 +2066,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerOutlet
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -2119,7 +2098,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Power Outlet [4-6]',
+            'name': 'Power Outlet [4-6]',
             'type': PowerOutletTypeChoices.TYPE_IEC_C13,
             'power_port': powerports[1].pk,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
@@ -2153,6 +2132,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = Interface
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -2217,7 +2197,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Interface [4-6]',
+            'name': 'Interface [4-6]',
             'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
             'enabled': False,
             'bridge': interfaces[4].pk,
@@ -2277,6 +2257,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
+    validation_excluded_fields = ('name', 'label', 'rear_port')
 
     @classmethod
     def setUpTestData(cls):
@@ -2312,11 +2293,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Front Port [4-6]',
+            'name': 'Front Port [4-6]',
             'type': PortTypeChoices.TYPE_8P8C,
-            'rear_port_set': [
-                '{}:1'.format(rp.pk) for rp in rearports[3:6]
-            ],
+            'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
             'description': 'New description',
             'tags': [t.pk for t in tags],
         }
@@ -2348,6 +2327,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = RearPort
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -2372,7 +2352,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Rear Port [4-6]',
+            'name': 'Rear Port [4-6]',
             'type': PortTypeChoices.TYPE_8P8C,
             'positions': 3,
             'description': 'A rear port',
@@ -2406,6 +2386,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ModuleBay
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -2428,7 +2409,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Module Bay [4-6]',
+            'name': 'Module Bay [4-6]',
             'description': 'A module bay',
             'tags': [t.pk for t in tags],
         }
@@ -2447,6 +2428,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = DeviceBay
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -2472,7 +2454,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Device Bay [4-6]',
+            'name': 'Device Bay [4-6]',
             'description': 'A device bay',
             'tags': [t.pk for t in tags],
         }
@@ -2491,6 +2473,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = InventoryItem
+    validation_excluded_fields = ('name', 'label')
 
     @classmethod
     def setUpTestData(cls):
@@ -2525,7 +2508,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'device': device.pk,
-            'name_pattern': 'Inventory Item [4-6]',
+            'name': 'Inventory Item [4-6]',
             'role': roles[1].pk,
             'manufacturer': manufacturer.pk,
             'parent': None,

+ 15 - 82
netbox/dcim/views.py

@@ -1120,9 +1120,8 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
 
 class ConsolePortTemplateCreateView(generic.ComponentCreateView):
     queryset = ConsolePortTemplate.objects.all()
-    form = forms.ModularComponentTemplateCreateForm
+    form = forms.ConsolePortTemplateCreateForm
     model_form = forms.ConsolePortTemplateForm
-    template_name = 'dcim/component_template_create.html'
 
 
 class ConsolePortTemplateEditView(generic.ObjectEditView):
@@ -1155,9 +1154,8 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
     queryset = ConsoleServerPortTemplate.objects.all()
-    form = forms.ModularComponentTemplateCreateForm
+    form = forms.ConsoleServerPortTemplateCreateForm
     model_form = forms.ConsoleServerPortTemplateForm
-    template_name = 'dcim/component_template_create.html'
 
 
 class ConsoleServerPortTemplateEditView(generic.ObjectEditView):
@@ -1190,9 +1188,8 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class PowerPortTemplateCreateView(generic.ComponentCreateView):
     queryset = PowerPortTemplate.objects.all()
-    form = forms.ModularComponentTemplateCreateForm
+    form = forms.PowerPortTemplateCreateForm
     model_form = forms.PowerPortTemplateForm
-    template_name = 'dcim/component_template_create.html'
 
 
 class PowerPortTemplateEditView(generic.ObjectEditView):
@@ -1225,9 +1222,8 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class PowerOutletTemplateCreateView(generic.ComponentCreateView):
     queryset = PowerOutletTemplate.objects.all()
-    form = forms.ModularComponentTemplateCreateForm
+    form = forms.PowerOutletTemplateCreateForm
     model_form = forms.PowerOutletTemplateForm
-    template_name = 'dcim/component_template_create.html'
 
 
 class PowerOutletTemplateEditView(generic.ObjectEditView):
@@ -1260,9 +1256,8 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class InterfaceTemplateCreateView(generic.ComponentCreateView):
     queryset = InterfaceTemplate.objects.all()
-    form = forms.ModularComponentTemplateCreateForm
+    form = forms.InterfaceTemplateCreateForm
     model_form = forms.InterfaceTemplateForm
-    template_name = 'dcim/component_template_create.html'
 
 
 class InterfaceTemplateEditView(generic.ObjectEditView):
@@ -1297,15 +1292,6 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
     queryset = FrontPortTemplate.objects.all()
     form = forms.FrontPortTemplateCreateForm
     model_form = forms.FrontPortTemplateForm
-    template_name = 'dcim/frontporttemplate_create.html'
-
-    def initialize_forms(self, request):
-        form, model_form = super().initialize_forms(request)
-
-        model_form.fields.pop('rear_port')
-        model_form.fields.pop('rear_port_position')
-
-        return form, model_form
 
 
 class FrontPortTemplateEditView(generic.ObjectEditView):
@@ -1338,9 +1324,8 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class RearPortTemplateCreateView(generic.ComponentCreateView):
     queryset = RearPortTemplate.objects.all()
-    form = forms.ModularComponentTemplateCreateForm
+    form = forms.RearPortTemplateCreateForm
     model_form = forms.RearPortTemplateForm
-    template_name = 'dcim/component_template_create.html'
 
 
 class RearPortTemplateEditView(generic.ObjectEditView):
@@ -1375,8 +1360,6 @@ class ModuleBayTemplateCreateView(generic.ComponentCreateView):
     queryset = ModuleBayTemplate.objects.all()
     form = forms.ModuleBayTemplateCreateForm
     model_form = forms.ModuleBayTemplateForm
-    template_name = 'dcim/modulebaytemplate_create.html'
-    patterned_fields = ('name', 'label', 'position')
 
 
 class ModuleBayTemplateEditView(generic.ObjectEditView):
@@ -1409,9 +1392,8 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class DeviceBayTemplateCreateView(generic.ComponentCreateView):
     queryset = DeviceBayTemplate.objects.all()
-    form = forms.ComponentTemplateCreateForm
+    form = forms.DeviceBayTemplateCreateForm
     model_form = forms.DeviceBayTemplateForm
-    template_name = 'dcim/component_template_create.html'
 
 
 class DeviceBayTemplateEditView(generic.ObjectEditView):
@@ -1444,9 +1426,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
 
 class InventoryItemTemplateCreateView(generic.ComponentCreateView):
     queryset = InventoryItemTemplate.objects.all()
-    form = forms.ModularComponentTemplateCreateForm
+    form = forms.InventoryItemTemplateCreateForm
     model_form = forms.InventoryItemTemplateForm
-    template_name = 'dcim/inventoryitemtemplate_create.html'
 
     def alter_object(self, instance, request):
         # Set component (if any)
@@ -1874,14 +1855,13 @@ class ConsolePortView(generic.ObjectView):
 
 class ConsolePortCreateView(generic.ComponentCreateView):
     queryset = ConsolePort.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
 
 
 class ConsolePortEditView(generic.ObjectEditView):
     queryset = ConsolePort.objects.all()
     form = forms.ConsolePortForm
-    template_name = 'dcim/device_component_edit.html'
 
 
 class ConsolePortDeleteView(generic.ObjectDeleteView):
@@ -1933,14 +1913,13 @@ class ConsoleServerPortView(generic.ObjectView):
 
 class ConsoleServerPortCreateView(generic.ComponentCreateView):
     queryset = ConsoleServerPort.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.ConsoleServerPortCreateForm
     model_form = forms.ConsoleServerPortForm
 
 
 class ConsoleServerPortEditView(generic.ObjectEditView):
     queryset = ConsoleServerPort.objects.all()
     form = forms.ConsoleServerPortForm
-    template_name = 'dcim/device_component_edit.html'
 
 
 class ConsoleServerPortDeleteView(generic.ObjectDeleteView):
@@ -1992,14 +1971,13 @@ class PowerPortView(generic.ObjectView):
 
 class PowerPortCreateView(generic.ComponentCreateView):
     queryset = PowerPort.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
 
 
 class PowerPortEditView(generic.ObjectEditView):
     queryset = PowerPort.objects.all()
     form = forms.PowerPortForm
-    template_name = 'dcim/device_component_edit.html'
 
 
 class PowerPortDeleteView(generic.ObjectDeleteView):
@@ -2051,14 +2029,13 @@ class PowerOutletView(generic.ObjectView):
 
 class PowerOutletCreateView(generic.ComponentCreateView):
     queryset = PowerOutlet.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.PowerOutletCreateForm
     model_form = forms.PowerOutletForm
 
 
 class PowerOutletEditView(generic.ObjectEditView):
     queryset = PowerOutlet.objects.all()
     form = forms.PowerOutletForm
-    template_name = 'dcim/device_component_edit.html'
 
 
 class PowerOutletDeleteView(generic.ObjectDeleteView):
@@ -2154,42 +2131,13 @@ class InterfaceView(generic.ObjectView):
 
 class InterfaceCreateView(generic.ComponentCreateView):
     queryset = Interface.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
-    # template_name = 'dcim/interface_create.html'
-
-    # TODO: Figure out what to do with this
-    # def post(self, request):
-    #     """
-    #     Override inherited post() method to handle request to assign newly created
-    #     interface objects (first object) to an IP Address object.
-    #     """
-    #     form = self.form(request.POST, initial=request.GET)
-    #     new_objs = self.validate_form(request, form)
-    #
-    #     if form.is_valid() and not form.errors:
-    #         if '_addanother' in request.POST:
-    #             return redirect(request.get_full_path())
-    #         elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
-    #                 request.user.has_perm('ipam.add_ipaddress'):
-    #             first_obj = new_objs[0].pk
-    #             return redirect(
-    #                 f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}'
-    #             )
-    #         else:
-    #             return redirect(self.get_return_url(request))
-    #
-    #     return render(request, self.template_name, {
-    #         'obj_type': self.queryset.model._meta.verbose_name,
-    #         'form': form,
-    #         'return_url': self.get_return_url(request),
-    #     })
 
 
 class InterfaceEditView(generic.ObjectEditView):
     queryset = Interface.objects.all()
     form = forms.InterfaceForm
-    template_name = 'dcim/interface_edit.html'
 
 
 class InterfaceDeleteView(generic.ObjectDeleteView):
@@ -2244,19 +2192,10 @@ class FrontPortCreateView(generic.ComponentCreateView):
     form = forms.FrontPortCreateForm
     model_form = forms.FrontPortForm
 
-    def initialize_forms(self, request):
-        form, model_form = super().initialize_forms(request)
-
-        model_form.fields.pop('rear_port')
-        model_form.fields.pop('rear_port_position')
-
-        return form, model_form
-
 
 class FrontPortEditView(generic.ObjectEditView):
     queryset = FrontPort.objects.all()
     form = forms.FrontPortForm
-    template_name = 'dcim/device_component_edit.html'
 
 
 class FrontPortDeleteView(generic.ObjectDeleteView):
@@ -2308,14 +2247,13 @@ class RearPortView(generic.ObjectView):
 
 class RearPortCreateView(generic.ComponentCreateView):
     queryset = RearPort.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.RearPortCreateForm
     model_form = forms.RearPortForm
 
 
 class RearPortEditView(generic.ObjectEditView):
     queryset = RearPort.objects.all()
     form = forms.RearPortForm
-    template_name = 'dcim/device_component_edit.html'
 
 
 class RearPortDeleteView(generic.ObjectDeleteView):
@@ -2369,13 +2307,11 @@ class ModuleBayCreateView(generic.ComponentCreateView):
     queryset = ModuleBay.objects.all()
     form = forms.ModuleBayCreateForm
     model_form = forms.ModuleBayForm
-    patterned_fields = ('name', 'label', 'position')
 
 
 class ModuleBayEditView(generic.ObjectEditView):
     queryset = ModuleBay.objects.all()
     form = forms.ModuleBayForm
-    template_name = 'dcim/device_component_edit.html'
 
 
 class ModuleBayDeleteView(generic.ObjectDeleteView):
@@ -2423,14 +2359,13 @@ class DeviceBayView(generic.ObjectView):
 
 class DeviceBayCreateView(generic.ComponentCreateView):
     queryset = DeviceBay.objects.all()
-    form = forms.DeviceComponentCreateForm
+    form = forms.DeviceBayCreateForm
     model_form = forms.DeviceBayForm
 
 
 class DeviceBayEditView(generic.ObjectEditView):
     queryset = DeviceBay.objects.all()
     form = forms.DeviceBayForm
-    template_name = 'dcim/device_component_edit.html'
 
 
 class DeviceBayDeleteView(generic.ObjectDeleteView):
@@ -2552,7 +2487,6 @@ class InventoryItemCreateView(generic.ComponentCreateView):
     queryset = InventoryItem.objects.all()
     form = forms.InventoryItemCreateForm
     model_form = forms.InventoryItemForm
-    template_name = 'dcim/inventoryitem_create.html'
 
     def alter_object(self, instance, request):
         # Set component (if any)
@@ -2736,7 +2670,6 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView):
     filterset = filtersets.DeviceFilterSet
     table = tables.DeviceTable
     default_return_url = 'dcim:device_list'
-    patterned_fields = ('name', 'label', 'position')
 
 
 class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):

+ 4 - 4
netbox/extras/api/views.py

@@ -159,7 +159,7 @@ class ReportViewSet(ViewSet):
         # Read the PK as "<module>.<report>"
         if '.' not in pk:
             raise Http404
-        module_name, report_name = pk.split('.', 1)
+        module_name, report_name = pk.split('.', maxsplit=1)
 
         # Raise a 404 on an invalid Report module/name
         report = get_report(module_name, report_name)
@@ -183,8 +183,8 @@ class ReportViewSet(ViewSet):
         }
 
         # Iterate through all available Reports.
-        for module_name, reports in get_reports():
-            for report in reports:
+        for module_name, reports in get_reports().items():
+            for report in reports.values():
 
                 # Attach the relevant JobResult (if any) to each Report.
                 report.result = results.get(report.full_name, None)
@@ -257,7 +257,7 @@ class ScriptViewSet(ViewSet):
     lookup_value_regex = '[^/]+'  # Allow dots
 
     def _get_script(self, pk):
-        module_name, script_name = pk.split('.')
+        module_name, script_name = pk.split('.', maxsplit=1)
         script = get_script(module_name, script_name)
         if script is None:
             raise Http404

+ 2 - 2
netbox/extras/management/commands/runreport.py

@@ -21,8 +21,8 @@ class Command(BaseCommand):
         reports = get_reports()
 
         # Run reports
-        for module_name, report_list in reports:
-            for report in report_list:
+        for module_name, report_list in reports.items():
+            for report in report_list.values():
                 if module_name in options['reports'] or report.full_name in options['reports']:
 
                     # Run the report and create a new JobResult

+ 17 - 10
netbox/extras/reports.py

@@ -26,20 +26,18 @@ def get_report(module_name, report_name):
     """
     Return a specific report from within a module.
     """
-    file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name)
+    reports = get_reports()
+    module = reports.get(module_name)
 
-    spec = importlib.util.spec_from_file_location(module_name, file_path)
-    module = importlib.util.module_from_spec(spec)
-    try:
-        spec.loader.exec_module(module)
-    except FileNotFoundError:
+    if module is None:
         return None
 
-    report = getattr(module, report_name, None)
+    report = module.get(report_name)
+
     if report is None:
         return None
 
-    return report()
+    return report
 
 
 def get_reports():
@@ -52,7 +50,7 @@ def get_reports():
         ...
     ]
     """
-    module_list = []
+    module_list = {}
 
     # Iterate through all modules within the reports path. These are the user-created files in which reports are
     # defined.
@@ -61,7 +59,16 @@ def get_reports():
         report_order = getattr(module, "report_order", ())
         ordered_reports = [cls() for cls in report_order if is_report(cls)]
         unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order]
-        module_list.append((module_name, [*ordered_reports, *unordered_reports]))
+
+        module_reports = {}
+
+        for cls in [*ordered_reports, *unordered_reports]:
+            # For reports in submodules use the full import path w/o the root module as the name
+            report_name = cls.full_name.split(".", maxsplit=1)[1]
+            module_reports[report_name] = cls
+
+        if module_reports:
+            module_list[module_name] = module_reports
 
     return module_list
 

+ 7 - 1
netbox/extras/scripts.py

@@ -299,6 +299,10 @@ class BaseScript:
     def module(cls):
         return cls.__module__
 
+    @classmethod
+    def root_module(cls):
+        return cls.__module__.split(".")[0]
+
     @classproperty
     def job_timeout(self):
         return getattr(self.Meta, 'job_timeout', None)
@@ -514,7 +518,9 @@ def get_scripts(use_names=False):
         ordered_scripts = [cls for cls in script_order if is_script(cls)]
         unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
         for cls in [*ordered_scripts, *unordered_scripts]:
-            module_scripts[cls.__name__] = cls
+            # For scripts in submodules use the full import path w/o the root module as the name
+            script_name = cls.full_name.split(".", maxsplit=1)[1]
+            module_scripts[script_name] = cls
         if module_scripts:
             scripts[module_name] = module_scripts
 

+ 3 - 3
netbox/extras/urls.py

@@ -1,4 +1,4 @@
-from django.urls import path
+from django.urls import path, re_path
 
 from extras import models, views
 from netbox.views.generic import ObjectChangeLogView
@@ -100,12 +100,12 @@ urlpatterns = [
 
     # Reports
     path('reports/', views.ReportListView.as_view(), name='report_list'),
-    path('reports/<str:module>.<str:name>/', views.ReportView.as_view(), name='report'),
     path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
+    re_path(r'^reports/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ReportView.as_view(), name='report'),
 
     # Scripts
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
-    path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
     path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
+    re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
 
 ]

+ 4 - 3
netbox/extras/views.py

@@ -534,9 +534,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
         }
 
         ret = []
-        for module, report_list in reports:
+
+        for module, report_list in reports.items():
             module_reports = []
-            for report in report_list:
+            for report in report_list.values():
                 report.result = results.get(report.full_name, None)
                 module_reports.append(report)
             ret.append((module, module_reports))
@@ -613,7 +614,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
         result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
 
         # Retrieve the Report and attach the JobResult to it
-        module, report_name = result.name.split('.')
+        module, report_name = result.name.split('.', maxsplit=1)
         report = get_report(module, report_name)
         report.result = result
 

+ 1 - 1
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.3.3-dev'
+VERSION = '3.3.4-dev'
 
 # Hostname
 HOSTNAME = platform.node()

+ 2 - 1
netbox/netbox/tables/columns.py

@@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
 from django.db.models import DateField, DateTimeField
 from django.template import Context, Template
 from django.urls import reverse
+from django.utils.encoding import escape_uri_path
 from django.utils.html import escape
 from django.utils.formats import date_format
 from django.utils.safestring import mark_safe
@@ -210,7 +211,7 @@ class ActionsColumn(tables.Column):
 
         model = table.Meta.model
         request = getattr(table, 'context', {}).get('request')
-        url_appendix = f'?return_url={request.path}' if request else ''
+        url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
         html = ''
 
         # Compile actions menu

+ 9 - 8
netbox/netbox/views/generic/bulk_views.py

@@ -774,7 +774,6 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
     model_form = None
     filterset = None
     table = None
-    patterned_fields = ('name', 'label')
 
     def get_required_permission(self):
         return f'dcim.add_{self.queryset.model._meta.model_name}'
@@ -804,23 +803,25 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
                 new_components = []
                 data = deepcopy(form.cleaned_data)
+                replication_data = {
+                    field: data.pop(field) for field in form.replication_fields
+                }
 
                 try:
                     with transaction.atomic():
 
                         for obj in data['pk']:
 
-                            pattern_count = len(data[f'{self.patterned_fields[0]}_pattern'])
+                            pattern_count = len(replication_data[form.replication_fields[0]])
                             for i in range(pattern_count):
                                 component_data = {
                                     self.parent_field: obj.pk
                                 }
-
-                                for field_name in self.patterned_fields:
-                                    if data.get(f'{field_name}_pattern'):
-                                        component_data[field_name] = data[f'{field_name}_pattern'][i]
-
                                 component_data.update(data)
+                                for field, values in replication_data.items():
+                                    if values:
+                                        component_data[field] = values[i]
+
                                 component_form = self.model_form(component_data)
                                 if component_form.is_valid():
                                     instance = component_form.save()
@@ -829,7 +830,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
                                 else:
                                     for field, errors in component_form.errors.as_data().items():
                                         for e in errors:
-                                            form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+                                            form.add_error(field, '{}: {}'.format(obj, ', '.join(e)))
 
                         # Enforce object-level permissions
                         if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):

+ 12 - 20
netbox/netbox/views/generic/object_views.py

@@ -538,10 +538,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     """
-    template_name = 'dcim/component_create.html'
+    template_name = 'generic/object_edit.html'
     form = None
     model_form = None
-    patterned_fields = ('name', 'label')
 
     def get_required_permission(self):
         return get_permission_for_model(self.queryset.model, 'add')
@@ -549,44 +548,38 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
     def alter_object(self, instance, request):
         return instance
 
-    def initialize_forms(self, request):
+    def initialize_form(self, request):
         data = request.POST if request.method == 'POST' else None
         initial_data = normalize_querydict(request.GET)
 
-        form = self.form(data=data, initial=request.GET)
-        model_form = self.model_form(data=data, initial=initial_data)
-
-        # These fields will be set from the pattern values
-        for field_name in self.patterned_fields:
-            model_form.fields[field_name].widget = HiddenInput()
+        form = self.form(data=data, initial=initial_data)
 
-        return form, model_form
+        return form
 
     def get(self, request):
-        form, model_form = self.initialize_forms(request)
+        form = self.initialize_form(request)
         instance = self.alter_object(self.queryset.model(), request)
 
         return render(request, self.template_name, {
             'object': instance,
-            'replication_form': form,
-            'form': model_form,
+            'form': form,
             'return_url': self.get_return_url(request),
         })
 
     def post(self, request):
         logger = logging.getLogger('netbox.views.ComponentCreateView')
-        form, model_form = self.initialize_forms(request)
+        form = self.initialize_form(request)
         instance = self.alter_object(self.queryset.model(), request)
 
         if form.is_valid():
             new_components = []
             data = deepcopy(request.POST)
-            pattern_count = len(form.cleaned_data[f'{self.patterned_fields[0]}_pattern'])
+            pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
 
             for i in range(pattern_count):
-                for field_name in self.patterned_fields:
-                    if form.cleaned_data.get(f'{field_name}_pattern'):
-                        data[field_name] = form.cleaned_data[f'{field_name}_pattern'][i]
+                for field_name in self.form.replication_fields:
+                    if form.cleaned_data.get(field_name):
+                        data[field_name] = form.cleaned_data[field_name][i]
 
                 if hasattr(form, 'get_iterative_data'):
                     data.update(form.get_iterative_data(i))
@@ -626,7 +619,6 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
 
         return render(request, self.template_name, {
             'object': instance,
-            'replication_form': form,
-            'form': model_form,
+            'form': form,
             'return_url': self.get_return_url(request),
         })

+ 0 - 38
netbox/templates/dcim/component_template_create.html

@@ -1,38 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-
-{% block form %}
-  {% if form.module_type %}
-    <div class="row mb-2">
-      <div class="offset-sm-3">
-        <ul class="nav nav-pills" role="tablist">
-          <li role="presentation" class="nav-item">
-            <button role="tab" type="button" id="devicetype_tab" data-bs-toggle="tab" aria-controls="devicetype" data-bs-target="#devicetype" class="nav-link {% if not form.initial.module_type %}active{% endif %}">
-              Device Type
-            </button>
-          </li>
-          <li role="presentation" class="nav-item">
-            <button role="tab" type="button" id="moduletype_tab" data-bs-toggle="tab" aria-controls="moduletype" data-bs-target="#moduletype" class="nav-link {% if form.initial.module_type %}active{% endif %}">
-              Module Type
-            </button>
-          </li>
-        </ul>
-      </div>
-    </div>
-    <div class="tab-content p-0 border-0">
-      <div class="tab-pane {% if not form.initial.module_type %}active{% endif %}" id="devicetype" role="tabpanel">
-        {% render_field replication_form.device_type %}
-      </div>
-      <div class="tab-pane {% if form.initial.module_type %}active{% endif %}" id="moduletype" role="tabpanel">
-        {% render_field replication_form.module_type %}
-      </div>
-    </div>
-  {% else %}
-    {% render_field replication_form.device_type %}
-  {% endif %}
-  {% block replication_fields %}
-    {% render_field replication_form.name_pattern %}
-    {% render_field replication_form.label_pattern %}
-  {% endblock replication_fields %}
-  {{ block.super }}
-{% endblock form %}

+ 0 - 16
netbox/templates/dcim/device_component_edit.html

@@ -1,16 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-
-{% block form %}
-  <div class="field-group mb-5">
-    {% if form.instance.device %}
-      <div class="row mb-3">
-        <label class="col-sm-3 col-form-label text-lg-end">Device</label>
-        <div class="col">
-          <input class="form-control" value="{{ form.instance.device }}" disabled />
-        </div>
-      </div>
-    {% endif %}
-    {% render_form form %}
-  </div>
-{% endblock form %}

+ 0 - 7
netbox/templates/dcim/frontporttemplate_create.html

@@ -1,7 +0,0 @@
-{% extends 'dcim/component_template_create.html' %}
-{% load form_helpers %}
-
-{% block replication_fields %}
-  {{ block.super }}
-  {% render_field replication_form.rear_port_set %}
-{% endblock replication_fields %}

+ 0 - 17
netbox/templates/dcim/inventoryitem_create.html

@@ -1,17 +0,0 @@
-{% extends 'dcim/component_create.html' %}
-{% load helpers %}
-{% load form_helpers %}
-
-{% block replication_fields %}
-  {{ block.super }}
-  {% if object.component %}
-    <div class="row mb-3">
-        <label class="col-sm-3 col-form-label text-lg-end">
-          {{ object.component|meta:"verbose_name"|bettertitle }}
-        </label>
-        <div class="col">
-            <input class="form-control" value="{{ object.component }}" disabled />
-        </div>
-    </div>
-  {% endif %}
-{% endblock replication_fields %}

+ 0 - 17
netbox/templates/dcim/inventoryitemtemplate_create.html

@@ -1,17 +0,0 @@
-{% extends 'dcim/component_template_create.html' %}
-{% load helpers %}
-{% load form_helpers %}
-
-{% block replication_fields %}
-  {{ block.super }}
-  {% if object.component %}
-    <div class="row mb-3">
-        <label class="col-sm-3 col-form-label text-lg-end">
-          {{ object.component|meta:"verbose_name"|bettertitle }}
-        </label>
-        <div class="col">
-            <input class="form-control" value="{{ object.component }}" disabled />
-        </div>
-    </div>
-  {% endif %}
-{% endblock replication_fields %}

+ 0 - 7
netbox/templates/dcim/modulebaytemplate_create.html

@@ -1,7 +0,0 @@
-{% extends 'dcim/component_template_create.html' %}
-{% load form_helpers %}
-
-{% block replication_fields %}
-  {{ block.super }}
-  {% render_field replication_form.position_pattern %}
-{% endblock replication_fields %}

+ 1 - 1
netbox/templates/extras/script_list.html

@@ -34,7 +34,7 @@
                 {% for class_name, script in module_scripts.items %}
                   <tr>
                     <td>
-                      <a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script.name }}</a>
+                      <a href="{% url 'extras:script' module=script.root_module name=class_name %}" name="script.{{ class_name }}">{{ script.name }}</a>
                     </td>
                     <td>
                       {% include 'extras/inc/job_label.html' with result=script.result %}

+ 5 - 3
netbox/templates/generic/object_edit.html

@@ -59,9 +59,11 @@ Context:
             {# Render grouped fields according to Form #}
             {% for group, fields in form.fieldsets %}
               <div class="field-group mb-5">
-                <div class="row mb-2">
-                  <h5 class="offset-sm-3">{{ group }}</h5>
-                </div>
+                {% if group %}
+                  <div class="row mb-2">
+                    <h5 class="offset-sm-3">{{ group }}</h5>
+                  </div>
+                {% endif %}
                 {% for name in fields %}
                   {% with field=form|getfield:name %}
                     {% if not field.field.widget.is_hidden %}

+ 8 - 0
netbox/templates/ipam/l2vpntermination_edit.html

@@ -46,4 +46,12 @@
       </div>
     </div>
   </div>
+ {% if form.custom_fields %}
+  <div class="field-group my-5">
+    <div class="row mb-2">
+      <h5 class="offset-sm-3">Custom Fields</h5>
+    </div>
+    {% render_custom_fields form %}
+  </div>
+{% endif %}
 {% endblock %}

+ 10 - 10
netbox/templates/login.html

@@ -13,6 +13,16 @@
       </div>
     {% endif %}
 
+    {# Login form errors #}
+    {% if form.non_field_errors %}
+      <div class="alert alert-danger" role="alert">
+        <h4 class="alert-heading">Errors</h4>
+        <p>
+          {{ form.non_field_errors }}
+        </p>
+      </div>
+    {% endif %}
+
     {# Login form #}
     <div class="form-login">
       <form action="{% url 'login' %}" method="post">
@@ -48,16 +58,6 @@
         </h5>
       {% endfor %}
     {% endif %}
-
-    {# Login form errors #}
-    {% if form.non_field_errors %}
-      <div class="alert alert-danger" role="alert">
-        <h4 class="alert-heading">Errors</h4>
-        <p>
-          {{ form.non_field_errors }}
-        </p>
-      </div>
-    {% endif %}
   </main>
 
   {# Page footer #}

+ 0 - 69
netbox/templates/virtualization/vminterface_edit.html

@@ -1,69 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-
-{% block form %}
-    {# Render hidden fields #}
-    {% for field in form.hidden_fields %}
-      {{ field }}
-    {% endfor %}
-
-    <div class="field-group my-5">
-      <div class="row mb-2">
-        <h5 class="offset-sm-3">Interface</h5>
-      </div>
-      {% if form.instance.virtual_machine %}
-        <div class="row mb-3">
-          <label class="col-sm-3 col-form-label text-lg-end required" for="id_device">Virtual Machine</label>
-          <div class="col">
-            <input class="form-control" value="{{ form.instance.virtual_machine }}" disabled />
-          </div>
-        </div>
-      {% endif %}
-      {% render_field form.name %}
-      {% render_field form.description %}
-      {% render_field form.tags %}
-    </div>
-
-    <div class="field-group my-5">
-        <div class="row mb-2">
-          <h5 class="offset-sm-3">Addressing</h5>
-        </div>
-      {% render_field form.vrf %}
-      {% render_field form.mac_address %}
-    </div>
-
-    <div class="field-group my-5">
-        <div class="row mb-2">
-          <h5 class="offset-sm-3">Operation</h5>
-        </div>
-      {% render_field form.mtu %}
-      {% render_field form.enabled %}
-    </div>
-
-    <div class="field-group my-5">
-        <div class="row mb-2">
-          <h5 class="offset-sm-3">Related Interfaces</h5>
-        </div>
-        {% render_field form.parent %}
-        {% render_field form.bridge %}
-    </div>
-
-    <div class="field-group my-5">
-      <div class="row mb-2">
-        <h5 class="offset-sm-3">802.1Q Switching</h5>
-      </div>
-      {% render_field form.mode %}
-      {% render_field form.vlan_group %}
-      {% render_field form.untagged_vlan %}
-      {% render_field form.tagged_vlans %}
-    </div>
-
-    {% if form.custom_fields %}
-      <div class="field-group my-5">
-        <div class="row mb-2">
-          <h5 class="offset-sm-3">Custom Fields</h5>
-        </div>
-        {% render_custom_fields form %}
-      </div>
-    {% endif %}
-{% endblock %}

+ 15 - 12
netbox/users/views.py

@@ -47,20 +47,14 @@ class LoginView(View):
             'url': f'{url}?{urlencode(params)}',
         }
 
-    def get(self, request):
-        form = LoginForm(request)
-
-        if request.user.is_authenticated:
-            logger = logging.getLogger('netbox.auth.login')
-            return self.redirect_to_next(request, logger)
-
+    def get_auth_backends(self, request):
         auth_backends = []
         saml_idps = get_saml_idps()
+
         for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
-            url = reverse('social:begin', args=[name, ])
+            url = reverse('social:begin', args=[name])
             params = {}
-            next = request.GET.get('next')
-            if next:
+            if next := request.GET.get('next'):
                 params['next'] = next
             if name.lower() == 'saml' and saml_idps:
                 for idp in saml_idps:
@@ -71,9 +65,18 @@ class LoginView(View):
             else:
                 auth_backends.append(self.gen_auth_data(name, url, params))
 
+        return auth_backends
+
+    def get(self, request):
+        form = LoginForm(request)
+
+        if request.user.is_authenticated:
+            logger = logging.getLogger('netbox.auth.login')
+            return self.redirect_to_next(request, logger)
+
         return render(request, self.template_name, {
             'form': form,
-            'auth_backends': auth_backends,
+            'auth_backends': self.get_auth_backends(request),
         })
 
     def post(self, request):
@@ -107,7 +110,7 @@ class LoginView(View):
 
         return render(request, self.template_name, {
             'form': form,
-            'auth_backends': load_backends(settings.AUTHENTICATION_BACKENDS),
+            'auth_backends': self.get_auth_backends(request),
         })
 
     def redirect_to_next(self, request, logger):

+ 1 - 1
netbox/utilities/forms/fields/expandable.py

@@ -22,7 +22,7 @@ class ExpandableNameField(forms.CharField):
         if not self.help_text:
             self.help_text = """
                 Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
-                are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>
+                are not supported (example: <code>[ge,xe]-0/0/[0-9]</code>).
                 """
 
     def to_python(self, value):

+ 3 - 2
netbox/utilities/testing/views.py

@@ -466,6 +466,7 @@ class ViewTestCases:
         """
         bulk_create_count = 3
         bulk_create_data = {}
+        validation_excluded_fields = []
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_create_multiple_objects_without_permission(self):
@@ -500,7 +501,7 @@ class ViewTestCases:
             self.assertHttpStatus(response, 302)
             self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count())
             for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]:
-                self.assertInstanceEqual(instance, self.bulk_create_data)
+                self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields)
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_create_multiple_objects_with_constrained_permission(self):
@@ -532,7 +533,7 @@ class ViewTestCases:
             self.assertHttpStatus(response, 302)
             self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count())
             for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]:
-                self.assertInstanceEqual(instance, self.bulk_create_data)
+                self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields)
 
     class BulkImportObjectsViewTestCase(ModelViewTestCase):
         """

+ 2 - 2
netbox/virtualization/forms/bulk_create.py

@@ -13,7 +13,7 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
         queryset=VirtualMachine.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
-    name_pattern = ExpandableNameField(
+    name = ExpandableNameField(
         label='Name'
     )
 
@@ -27,4 +27,4 @@ class VMInterfaceBulkCreateForm(
     form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']),
     VirtualMachineBulkAddComponentForm
 ):
-    pass
+    replication_fields = ('name',)

+ 10 - 2
netbox/virtualization/forms/models.py

@@ -5,7 +5,6 @@ from django.core.exceptions import ValidationError
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.models import INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
-from extras.models import Tag
 from ipam.models import IPAddress, VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
@@ -278,6 +277,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
 
 
 class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all()
+    )
     parent = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         required=False,
@@ -338,7 +340,6 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
             'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
         ]
         widgets = {
-            'virtual_machine': forms.HiddenInput(),
             'mode': StaticSelect()
         }
         labels = {
@@ -347,3 +348,10 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
         help_texts = {
             'mode': INTERFACE_MODE_HELP_TEXT,
         }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Disable reassignment of VirtualMachine when editing an existing instance
+        if self.instance.pk:
+            self.fields['virtual_machine'].disabled = True

+ 8 - 11
netbox/virtualization/forms/object_create.py

@@ -1,17 +1,14 @@
-from django import forms
-
-from utilities.forms import BootstrapMixin, DynamicModelChoiceField, ExpandableNameField
-from .models import VirtualMachine
+from utilities.forms import ExpandableNameField
+from .models import VMInterfaceForm
 
 __all__ = (
     'VMInterfaceCreateForm',
 )
 
 
-class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
-    virtual_machine = DynamicModelChoiceField(
-        queryset=VirtualMachine.objects.all()
-    )
-    name_pattern = ExpandableNameField(
-        label='Name'
-    )
+class VMInterfaceCreateForm(VMInterfaceForm):
+    name = ExpandableNameField()
+    replication_fields = ('name',)
+
+    class Meta(VMInterfaceForm.Meta):
+        exclude = ('name',)

+ 4 - 3
netbox/virtualization/tests/test_views.py

@@ -251,6 +251,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = VMInterface
+    validation_excluded_fields = ('name',)
 
     @classmethod
     def setUpTestData(cls):
@@ -290,10 +291,10 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
         cls.form_data = {
-            'virtual_machine': virtualmachines[1].pk,
+            'virtual_machine': virtualmachines[0].pk,
             'name': 'Interface X',
             'enabled': False,
-            'bridge': interfaces[3].pk,
+            'bridge': interfaces[1].pk,
             'mac_address': EUI('01-02-03-04-05-06'),
             'mtu': 65000,
             'description': 'New description',
@@ -306,7 +307,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
         cls.bulk_create_data = {
             'virtual_machine': virtualmachines[1].pk,
-            'name_pattern': 'Interface [4-6]',
+            'name': 'Interface [4-6]',
             'enabled': False,
             'bridge': interfaces[3].pk,
             'mac_address': EUI('01-02-03-04-05-06'),

+ 0 - 2
netbox/virtualization/views.py

@@ -451,13 +451,11 @@ class VMInterfaceCreateView(generic.ComponentCreateView):
     queryset = VMInterface.objects.all()
     form = forms.VMInterfaceCreateForm
     model_form = forms.VMInterfaceForm
-    patterned_fields = ('name',)
 
 
 class VMInterfaceEditView(generic.ObjectEditView):
     queryset = VMInterface.objects.all()
     form = forms.VMInterfaceForm
-    template_name = 'virtualization/vminterface_edit.html'
 
 
 class VMInterfaceDeleteView(generic.ObjectDeleteView):

+ 2 - 2
requirements.txt

@@ -19,13 +19,13 @@ graphene-django==2.15.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Markdown==3.4.1
-mkdocs-material==8.4.2
+mkdocs-material==8.5.1
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 Pillow==9.2.0
 psycopg2-binary==2.9.3
 PyYAML==6.0
-sentry-sdk==1.9.7
+sentry-sdk==1.9.8
 social-auth-app-django==5.0.0
 social-auth-core==4.3.0
 svgwrite==1.4.3