Quellcode durchsuchen

Merge branch 'develop' into 3995-navbar-overflow

Jeremy Stretch vor 6 Jahren
Ursprung
Commit
5befa533c6
43 geänderte Dateien mit 1596 neuen und 789 gelöschten Zeilen
  1. 1 1
      docs/installation/index.md
  2. 1 1
      docs/installation/upgrading.md
  3. 12 1
      docs/release-notes/version-2.7.md
  4. 27 16
      netbox/circuits/forms.py
  5. 18 10
      netbox/dcim/api/serializers.py
  6. 223 153
      netbox/dcim/forms.py
  7. 1 55
      netbox/dcim/managers.py
  8. 147 0
      netbox/dcim/migrations/0093_device_component_ordering.py
  9. 138 0
      netbox/dcim/migrations/0094_device_component_template_ordering.py
  10. 70 0
      netbox/dcim/migrations/0095_primary_model_ordering.py
  11. 53 0
      netbox/dcim/migrations/0096_interface_ordering.py
  12. 29 20
      netbox/dcim/models/__init__.py
  13. 64 37
      netbox/dcim/models/device_component_templates.py
  14. 72 41
      netbox/dcim/models/device_components.py
  15. 23 3
      netbox/dcim/tables.py
  16. 12 10
      netbox/dcim/tests/test_views.py
  17. 12 2
      netbox/dcim/urls.py
  18. 91 9
      netbox/dcim/views.py
  19. 83 42
      netbox/extras/forms.py
  20. 2 2
      netbox/ipam/api/serializers.py
  21. 8 6
      netbox/ipam/filters.py
  22. 97 90
      netbox/ipam/forms.py
  23. 5 6
      netbox/ipam/tests/test_filters.py
  24. 6 7
      netbox/project-static/js/forms.js
  25. 11 9
      netbox/secrets/forms.py
  26. 2 4
      netbox/templates/dcim/consoleport_list.html
  27. 17 0
      netbox/templates/dcim/consoleserverport_list.html
  28. 17 0
      netbox/templates/dcim/devicebay_list.html
  29. 17 0
      netbox/templates/dcim/frontport_list.html
  30. 17 0
      netbox/templates/dcim/interface_list.html
  31. 17 0
      netbox/templates/dcim/poweroutlet_list.html
  32. 17 0
      netbox/templates/dcim/powerport_list.html
  33. 17 0
      netbox/templates/dcim/rearport_list.html
  34. 6 1
      netbox/templates/inc/nav_menu.html
  35. 22 23
      netbox/tenancy/forms.py
  36. 19 2
      netbox/utilities/api.py
  37. 33 0
      netbox/utilities/fields.py
  38. 26 105
      netbox/utilities/forms.py
  39. 0 45
      netbox/utilities/managers.py
  40. 80 0
      netbox/utilities/ordering.py
  41. 0 9
      netbox/utilities/templates/widgets/select_api.html
  42. 1 1
      netbox/virtualization/api/serializers.py
  43. 82 78
      netbox/virtualization/forms.py

+ 1 - 1
docs/installation/index.md

@@ -4,7 +4,7 @@ The following sections detail how to set up a new instance of NetBox:
 
 
 1. [PostgreSQL database](1-postgresql.md)
 1. [PostgreSQL database](1-postgresql.md)
 2. [NetBox components](2-netbox.md)
 2. [NetBox components](2-netbox.md)
-3. [HTTP dameon](3-http-daemon.md)
+3. [HTTP daemon](3-http-daemon.md)
 4. [LDAP authentication](4-ldap.md) (optional)
 4. [LDAP authentication](4-ldap.md) (optional)
 
 
 # Upgrading
 # Upgrading

+ 1 - 1
docs/installation/upgrading.md

@@ -88,7 +88,7 @@ Finally, restart the WSGI services to run the new code. If you followed this gui
 
 
 ```no-highlight
 ```no-highlight
 # sudo systemctl restart netbox
 # sudo systemctl restart netbox
-# sudo systemctl restart netbox-rqworker
+# sudo systemctl restart netbox-rq
 ```
 ```
 
 
 !!! note
 !!! note

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

@@ -2,15 +2,26 @@
 
 
 ## Enhancements
 ## Enhancements
 
 
-* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable
+* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components
+* [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views
 * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components
 * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components
+* [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views
+* [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components
 
 
 ## Bug Fixes
 ## Bug Fixes
 
 
+* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices
+* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable
+* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests
 * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional
 * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional
 * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view
 * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view
 * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form
 * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form
 * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list
 * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list
+* [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms
+* [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type
+* [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit
+
+---
 
 
 # v2.7.4 (2020-02-04)
 # v2.7.4 (2020-02-04)
 
 

+ 27 - 16
netbox/circuits/forms.py

@@ -9,7 +9,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker,
     APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker,
-    FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField
+    DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
+    StaticSelect2Multiple, TagFilterField,
 )
 )
 from .choices import CircuitStatusChoices
 from .choices import CircuitStatusChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -107,7 +108,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -119,9 +120,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
@@ -164,6 +166,18 @@ class CircuitTypeCSVForm(forms.ModelForm):
 #
 #
 
 
 class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all(),
+        widget=APISelect(
+            api_url="/api/circuits/providers/"
+        )
+    )
+    type = DynamicModelChoiceField(
+        queryset=CircuitType.objects.all(),
+        widget=APISelect(
+            api_url="/api/circuits/circuit-types/"
+        )
+    )
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
         required=False
         required=False
@@ -180,12 +194,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             'commit_rate': "Committed rate",
             'commit_rate': "Committed rate",
         }
         }
         widgets = {
         widgets = {
-            'provider': APISelect(
-                api_url="/api/circuits/providers/"
-            ),
-            'type': APISelect(
-                api_url="/api/circuits/circuit-types/"
-            ),
             'status': StaticSelect2(),
             'status': StaticSelect2(),
             'install_date': DatePicker(),
             'install_date': DatePicker(),
         }
         }
@@ -235,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
     )
     )
-    type = forms.ModelChoiceField(
+    type = DynamicModelChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/circuits/circuit-types/"
             api_url="/api/circuits/circuit-types/"
         )
         )
     )
     )
-    provider = forms.ModelChoiceField(
+    provider = DynamicModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -255,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
         initial='',
         initial='',
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -290,17 +298,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    type = FilterChoiceField(
+    type = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/circuits/circuit-types/",
             api_url="/api/circuits/circuit-types/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    provider = FilterChoiceField(
+    provider = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/circuits/providers/",
             api_url="/api/circuits/providers/",
             value_field="slug",
             value_field="slug",
@@ -311,7 +321,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    region = forms.ModelMultipleChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -323,9 +333,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",

+ 18 - 10
netbox/dcim/api/serializers.py

@@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=RackStatusChoices, required=False)
     status = ChoiceField(choices=RackStatusChoices, required=False)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
-    type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True)
+    type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
     width = ChoiceField(choices=RackWidthChoices, required=False)
     width = ChoiceField(choices=RackWidthChoices, required=False)
-    outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False)
+    outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
@@ -212,7 +212,7 @@ class ManufacturerSerializer(ValidatedModelSerializer):
 
 
 class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
     manufacturer = NestedManufacturerSerializer()
     manufacturer = NestedManufacturerSerializer()
-    subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True)
+    subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
 
 
@@ -228,6 +228,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
 
 
@@ -240,6 +241,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
 
 
@@ -252,6 +254,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
 
 
@@ -264,6 +267,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     power_port = PowerPortTemplateSerializer(
     power_port = PowerPortTemplateSerializer(
@@ -271,8 +275,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     )
     )
     feed_leg = ChoiceField(
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
-        required=False,
-        allow_null=True
+        allow_blank=True,
+        required=False
     )
     )
 
 
     class Meta:
     class Meta:
@@ -351,7 +355,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
     rack = NestedRackSerializer(required=False, allow_null=True)
     rack = NestedRackSerializer(required=False, allow_null=True)
-    face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True)
+    face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False)
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
@@ -420,6 +424,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
@@ -437,6 +442,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
@@ -454,6 +460,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     power_port = NestedPowerPortSerializer(
     power_port = NestedPowerPortSerializer(
@@ -461,8 +468,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     )
     )
     feed_leg = ChoiceField(
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
-        required=False,
-        allow_null=True
+        allow_blank=True,
+        required=False
     )
     )
     cable = NestedCableSerializer(
     cable = NestedCableSerializer(
         read_only=True
         read_only=True
@@ -483,6 +490,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
@@ -500,7 +508,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices, required=False)
     type = ChoiceField(choices=InterfaceTypeChoices, required=False)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
-    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
@@ -617,7 +625,7 @@ class CableSerializer(ValidatedModelSerializer):
     termination_a = serializers.SerializerMethodField(read_only=True)
     termination_a = serializers.SerializerMethodField(read_only=True)
     termination_b = serializers.SerializerMethodField(read_only=True)
     termination_b = serializers.SerializerMethodField(read_only=True)
     status = ChoiceField(choices=CableStatusChoices, required=False)
     status = ChoiceField(choices=CableStatusChoices, required=False)
-    length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True)
+    length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
 
 
     class Meta:
     class Meta:
         model = Cable
         model = Cable

Datei-Diff unterdrückt, da er zu groß ist
+ 223 - 153
netbox/dcim/forms.py


+ 1 - 55
netbox/dcim/managers.py

@@ -1,18 +1,7 @@
 from django.db.models import Manager, QuerySet
 from django.db.models import Manager, QuerySet
-from django.db.models.expressions import RawSQL
 
 
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .constants import NONCONNECTABLE_IFACE_TYPES
 
 
-# Regular expressions for parsing Interface names
-TYPE_RE = r"SUBSTRING({} FROM '^([^0-9\.:]+)')"
-SLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})/') AS integer), NULL)"
-SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?\d{{1,9}}/(\d{{1,9}})') AS integer), NULL)"
-POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{2}}(\d{{1,9}})') AS integer), NULL)"
-SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{3}}(\d{{1,9}})') AS integer), NULL)"
-ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?(\d{{1,9}})([^/]|$)') AS integer)"
-CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)"
-VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)"
-
 
 
 class InterfaceQuerySet(QuerySet):
 class InterfaceQuerySet(QuerySet):
 
 
@@ -27,47 +16,4 @@ class InterfaceQuerySet(QuerySet):
 class InterfaceManager(Manager):
 class InterfaceManager(Manager):
 
 
     def get_queryset(self):
     def get_queryset(self):
-        """
-        Naturally order interfaces by their type and numeric position. To order interfaces naturally, the `name` field
-        is split into eight distinct components: leading text (type), slot, subslot, position, subposition, ID, channel,
-        and virtual circuit:
-
-            {type}{slot or ID}/{subslot}/{position}/{subposition}:{channel}.{vc}
-
-        Components absent from the interface name are coalesced to zero or null. For example, an interface named
-        GigabitEthernet1/2/3 would be parsed as follows:
-
-            type = 'GigabitEthernet'
-            slot =  1
-            subslot = 2
-            position = 3
-            subposition = None
-            id = None
-            channel = 0
-            vc = 0
-
-        The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not
-        match any of the prescribed fields.
-
-        The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device
-        components.
-        """
-
-        sql_col = '{}.name'.format(self.model._meta.db_table)
-        ordering = [
-            '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk'
-
-        ]
-
-        fields = {
-            '_type': RawSQL(TYPE_RE.format(sql_col), []),
-            '_id': RawSQL(ID_RE.format(sql_col), []),
-            '_slot': RawSQL(SLOT_RE.format(sql_col), []),
-            '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
-            '_position': RawSQL(POSITION_RE.format(sql_col), []),
-            '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
-            '_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
-            '_vc': RawSQL(VC_RE.format(sql_col), []),
-        }
-
-        return InterfaceQuerySet(self.model, using=self._db).annotate(**fields).order_by(*ordering)
+        return InterfaceQuerySet(self.model, using=self._db)

+ 147 - 0
netbox/dcim/migrations/0093_device_component_ordering.py

@@ -0,0 +1,147 @@
+from django.db import migrations
+import utilities.fields
+import utilities.ordering
+
+
+def _update_model_names(model):
+    # Update each unique field value in bulk
+    for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
+        model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name))
+
+
+def naturalize_consoleports(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'ConsolePort'))
+
+
+def naturalize_consoleserverports(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'ConsoleServerPort'))
+
+
+def naturalize_powerports(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'PowerPort'))
+
+
+def naturalize_poweroutlets(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'PowerOutlet'))
+
+
+def naturalize_frontports(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'FrontPort'))
+
+
+def naturalize_rearports(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'RearPort'))
+
+
+def naturalize_devicebays(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'DeviceBay'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0092_fix_rack_outer_unit'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='consoleport',
+            options={'ordering': ('device', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='consoleserverport',
+            options={'ordering': ('device', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='devicebay',
+            options={'ordering': ('device', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='frontport',
+            options={'ordering': ('device', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='inventoryitem',
+            options={'ordering': ('device__id', 'parent__id', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='poweroutlet',
+            options={'ordering': ('device', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='powerport',
+            options={'ordering': ('device', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='rearport',
+            options={'ordering': ('device', '_name')},
+        ),
+        migrations.AddField(
+            model_name='consoleport',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='frontport',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='rearport',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.RunPython(
+            code=naturalize_consoleports,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_consoleserverports,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_powerports,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_poweroutlets,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_frontports,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_rearports,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_devicebays,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 138 - 0
netbox/dcim/migrations/0094_device_component_template_ordering.py

@@ -0,0 +1,138 @@
+from django.db import migrations
+import utilities.fields
+import utilities.ordering
+
+
+def _update_model_names(model):
+    # Update each unique field value in bulk
+    for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
+        model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name))
+
+
+def naturalize_consoleporttemplates(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'ConsolePortTemplate'))
+
+
+def naturalize_consoleserverporttemplates(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'ConsoleServerPortTemplate'))
+
+
+def naturalize_powerporttemplates(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'PowerPortTemplate'))
+
+
+def naturalize_poweroutlettemplates(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'PowerOutletTemplate'))
+
+
+def naturalize_frontporttemplates(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'FrontPortTemplate'))
+
+
+def naturalize_rearporttemplates(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'RearPortTemplate'))
+
+
+def naturalize_devicebaytemplates(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'DeviceBayTemplate'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0093_device_component_ordering'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='consoleporttemplate',
+            options={'ordering': ('device_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='consoleserverporttemplate',
+            options={'ordering': ('device_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='devicebaytemplate',
+            options={'ordering': ('device_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='frontporttemplate',
+            options={'ordering': ('device_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='poweroutlettemplate',
+            options={'ordering': ('device_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='powerporttemplate',
+            options={'ordering': ('device_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='rearporttemplate',
+            options={'ordering': ('device_type', '_name')},
+        ),
+        migrations.AddField(
+            model_name='consoleporttemplate',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='consoleserverporttemplate',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='devicebaytemplate',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='frontporttemplate',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='poweroutlettemplate',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='powerporttemplate',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='rearporttemplate',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.RunPython(
+            code=naturalize_consoleporttemplates,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_consoleserverporttemplates,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_powerporttemplates,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_poweroutlettemplates,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_frontporttemplates,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_rearporttemplates,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_devicebaytemplates,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 70 - 0
netbox/dcim/migrations/0095_primary_model_ordering.py

@@ -0,0 +1,70 @@
+from django.db import migrations
+import utilities.fields
+import utilities.ordering
+
+
+def _update_model_names(model):
+    # Update each unique field value in bulk
+    for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
+        model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name))
+
+
+def naturalize_sites(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'Site'))
+
+
+def naturalize_racks(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'Rack'))
+
+
+def naturalize_devices(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'Device'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0094_device_component_template_ordering'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='device',
+            options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
+        ),
+        migrations.AlterModelOptions(
+            name='rack',
+            options={'ordering': ('site', 'group', '_name', 'pk')},
+        ),
+        migrations.AlterModelOptions(
+            name='site',
+            options={'ordering': ('_name',)},
+        ),
+        migrations.AddField(
+            model_name='device',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
+        ),
+        migrations.AddField(
+            model_name='rack',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.AddField(
+            model_name='site',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+        ),
+        migrations.RunPython(
+            code=naturalize_sites,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_racks,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_devices,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 53 - 0
netbox/dcim/migrations/0096_interface_ordering.py

@@ -0,0 +1,53 @@
+from django.db import migrations
+import utilities.fields
+import utilities.ordering
+
+
+def _update_model_names(model):
+    # Update each unique field value in bulk
+    for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
+        model.objects.filter(name=name).update(_name=utilities.ordering.naturalize_interface(name))
+
+
+def naturalize_interfacetemplates(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'InterfaceTemplate'))
+
+
+def naturalize_interfaces(apps, schema_editor):
+    _update_model_names(apps.get_model('dcim', 'Interface'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0095_primary_model_ordering'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='interface',
+            options={'ordering': ('device', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='interfacetemplate',
+            options={'ordering': ('device_type', '_name')},
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='_name',
+            field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
+        ),
+        migrations.RunPython(
+            code=naturalize_interfacetemplates,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=naturalize_interfaces,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 29 - 20
netbox/dcim/models/__init__.py

@@ -22,8 +22,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
 from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
-from utilities.fields import ColorField
-from utilities.managers import NaturalOrderingManager
+from utilities.fields import ColorField, NaturalOrderingField
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from utilities.utils import foreground_color, to_meters
 from utilities.utils import foreground_color, to_meters
 from .device_component_templates import (
 from .device_component_templates import (
@@ -134,6 +133,11 @@ class Site(ChangeLoggedModel, CustomFieldModel):
         max_length=50,
         max_length=50,
         unique=True
         unique=True
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     slug = models.SlugField(
     slug = models.SlugField(
         unique=True
         unique=True
     )
     )
@@ -215,8 +219,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
@@ -235,7 +237,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     }
     }
 
 
     class Meta:
     class Meta:
-        ordering = ['name']
+        ordering = ('_name',)
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -516,6 +518,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     facility_id = models.CharField(
     facility_id = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
@@ -612,8 +619,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
@@ -634,12 +639,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     }
     }
 
 
     class Meta:
     class Meta:
-        ordering = ('site', 'group', 'name', 'pk')  # (site, group, name) may be non-unique
-        unique_together = [
+        ordering = ('site', 'group', '_name', 'pk')  # (site, group, name) may be non-unique
+        unique_together = (
             # Name and facility_id must be unique *only* within a RackGroup
             # Name and facility_id must be unique *only* within a RackGroup
-            ['group', 'name'],
-            ['group', 'facility_id'],
-        ]
+            ('group', 'name'),
+            ('group', 'facility_id'),
+        )
 
 
     def __str__(self):
     def __str__(self):
         return self.display_name or super().__str__()
         return self.display_name or super().__str__()
@@ -1313,6 +1318,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True,
+        null=True
+    )
     serial = models.CharField(
     serial = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
@@ -1407,8 +1418,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
@@ -1430,12 +1439,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     }
     }
 
 
     class Meta:
     class Meta:
-        ordering = ('name', 'pk')  # Name may be NULL
-        unique_together = [
-            ['site', 'tenant', 'name'],  # See validate_unique below
-            ['rack', 'position', 'face'],
-            ['virtual_chassis', 'vc_position'],
-        ]
+        ordering = ('_name', 'pk')  # Name may be null
+        unique_together = (
+            ('site', 'tenant', 'name'),  # See validate_unique below
+            ('rack', 'position', 'face'),
+            ('virtual_chassis', 'vc_position'),
+        )
         permissions = (
         permissions = (
             ('napalm_read', 'Read-only access to devices via NAPALM'),
             ('napalm_read', 'Read-only access to devices via NAPALM'),
             ('napalm_write', 'Read/write access to devices via NAPALM'),
             ('napalm_write', 'Read/write access to devices via NAPALM'),

+ 64 - 37
netbox/dcim/models/device_component_templates.py

@@ -4,9 +4,9 @@ from django.db import models
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
-from dcim.managers import InterfaceManager
 from extras.models import ObjectChange
 from extras.models import ObjectChange
-from utilities.managers import NaturalOrderingManager
+from utilities.fields import NaturalOrderingField
+from utilities.ordering import naturalize_interface
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
 from .device_components import (
 from .device_components import (
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
@@ -58,17 +58,20 @@ class ConsolePortTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         blank=True
         blank=True
     )
     )
 
 
-    objects = NaturalOrderingManager()
-
     class Meta:
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -93,17 +96,20 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
         blank=True
         blank=True
     )
     )
 
 
-    objects = NaturalOrderingManager()
-
     class Meta:
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -128,6 +134,11 @@ class PowerPortTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
@@ -146,11 +157,9 @@ class PowerPortTemplate(ComponentTemplateModel):
         help_text="Allocated power draw (watts)"
         help_text="Allocated power draw (watts)"
     )
     )
 
 
-    objects = NaturalOrderingManager()
-
     class Meta:
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -159,6 +168,7 @@ class PowerPortTemplate(ComponentTemplateModel):
         return PowerPort(
         return PowerPort(
             device=device,
             device=device,
             name=self.name,
             name=self.name,
+            type=self.type,
             maximum_draw=self.maximum_draw,
             maximum_draw=self.maximum_draw,
             allocated_draw=self.allocated_draw
             allocated_draw=self.allocated_draw
         )
         )
@@ -176,6 +186,11 @@ class PowerOutletTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
@@ -195,11 +210,9 @@ class PowerOutletTemplate(ComponentTemplateModel):
         help_text="Phase (for three-phase feeds)"
         help_text="Phase (for three-phase feeds)"
     )
     )
 
 
-    objects = NaturalOrderingManager()
-
     class Meta:
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -220,6 +233,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
         return PowerOutlet(
         return PowerOutlet(
             device=device,
             device=device,
             name=self.name,
             name=self.name,
+            type=self.type,
             power_port=power_port,
             power_port=power_port,
             feed_leg=self.feed_leg
             feed_leg=self.feed_leg
         )
         )
@@ -237,6 +251,12 @@ class InterfaceTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=64
         max_length=64
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        naturalize_function=naturalize_interface,
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=InterfaceTypeChoices
         choices=InterfaceTypeChoices
@@ -246,11 +266,9 @@ class InterfaceTemplate(ComponentTemplateModel):
         verbose_name='Management only'
         verbose_name='Management only'
     )
     )
 
 
-    objects = InterfaceManager()
-
     class Meta:
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -276,6 +294,11 @@ class FrontPortTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=64
         max_length=64
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PortTypeChoices
         choices=PortTypeChoices
@@ -290,14 +313,12 @@ class FrontPortTemplate(ComponentTemplateModel):
         validators=[MinValueValidator(1), MaxValueValidator(64)]
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
     )
 
 
-    objects = NaturalOrderingManager()
-
     class Meta:
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = [
-            ['device_type', 'name'],
-            ['rear_port', 'rear_port_position'],
-        ]
+        ordering = ('device_type', '_name')
+        unique_together = (
+            ('device_type', 'name'),
+            ('rear_port', 'rear_port_position'),
+        )
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -344,6 +365,11 @@ class RearPortTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=64
         max_length=64
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PortTypeChoices
         choices=PortTypeChoices
@@ -353,11 +379,9 @@ class RearPortTemplate(ComponentTemplateModel):
         validators=[MinValueValidator(1), MaxValueValidator(64)]
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
     )
 
 
-    objects = NaturalOrderingManager()
-
     class Meta:
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -383,12 +407,15 @@ class DeviceBayTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
-
-    objects = NaturalOrderingManager()
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
 
 
     class Meta:
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name

+ 72 - 41
netbox/dcim/models/device_components.py

@@ -10,9 +10,9 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.exceptions import LoopDetected
 from dcim.exceptions import LoopDetected
 from dcim.fields import MACAddressField
 from dcim.fields import MACAddressField
-from dcim.managers import InterfaceManager
 from extras.models import ObjectChange, TaggedItem
 from extras.models import ObjectChange, TaggedItem
-from utilities.managers import NaturalOrderingManager
+from utilities.fields import NaturalOrderingField
+from utilities.ordering import naturalize_interface
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
 from virtualization.choices import VMInterfaceTypeChoices
 from virtualization.choices import VMInterfaceTypeChoices
 
 
@@ -181,6 +181,11 @@ class ConsolePort(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
@@ -197,15 +202,13 @@ class ConsolePort(CableTermination, ComponentModel):
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
         blank=True
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'description']
     csv_headers = ['device', 'name', 'type', 'description']
 
 
     class Meta:
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -238,6 +241,11 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
@@ -247,14 +255,13 @@ class ConsoleServerPort(CableTermination, ComponentModel):
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
         blank=True
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'description']
     csv_headers = ['device', 'name', 'type', 'description']
 
 
     class Meta:
     class Meta:
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -287,6 +294,11 @@ class PowerPort(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
@@ -322,15 +334,13 @@ class PowerPort(CableTermination, ComponentModel):
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
         blank=True
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
     csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
 
 
     class Meta:
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -433,6 +443,11 @@ class PowerOutlet(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
@@ -455,14 +470,13 @@ class PowerOutlet(CableTermination, ComponentModel):
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
         blank=True
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
     csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
 
 
     class Meta:
     class Meta:
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -515,6 +529,12 @@ class Interface(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=64
         max_length=64
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        naturalize_function=naturalize_interface,
+        max_length=100,
+        blank=True
+    )
     _connected_interface = models.OneToOneField(
     _connected_interface = models.OneToOneField(
         to='self',
         to='self',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -583,8 +603,6 @@ class Interface(CableTermination, ComponentModel):
         blank=True,
         blank=True,
         verbose_name='Tagged VLANs'
         verbose_name='Tagged VLANs'
     )
     )
-
-    objects = InterfaceManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
@@ -593,8 +611,9 @@ class Interface(CableTermination, ComponentModel):
     ]
     ]
 
 
     class Meta:
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        # TODO: ordering and unique_together should include virtual_machine
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -761,6 +780,11 @@ class FrontPort(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=64
         max_length=64
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PortTypeChoices
         choices=PortTypeChoices
@@ -774,20 +798,17 @@ class FrontPort(CableTermination, ComponentModel):
         default=1,
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
     )
-
-    is_path_endpoint = False
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
     csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
+    is_path_endpoint = False
 
 
     class Meta:
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = [
-            ['device', 'name'],
-            ['rear_port', 'rear_port_position'],
-        ]
+        ordering = ('device', '_name')
+        unique_together = (
+            ('device', 'name'),
+            ('rear_port', 'rear_port_position'),
+        )
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -831,6 +852,11 @@ class RearPort(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=64
         max_length=64
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
     type = models.CharField(
         max_length=50,
         max_length=50,
         choices=PortTypeChoices
         choices=PortTypeChoices
@@ -839,17 +865,14 @@ class RearPort(CableTermination, ComponentModel):
         default=1,
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
     )
-
-    is_path_endpoint = False
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
+    is_path_endpoint = False
 
 
     class Meta:
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -881,6 +904,11 @@ class DeviceBay(ComponentModel):
         max_length=50,
         max_length=50,
         verbose_name='Name'
         verbose_name='Name'
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     installed_device = models.OneToOneField(
     installed_device = models.OneToOneField(
         to='dcim.Device',
         to='dcim.Device',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -888,15 +916,13 @@ class DeviceBay(ComponentModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'installed_device', 'description']
     csv_headers = ['device', 'name', 'installed_device', 'description']
 
 
     class Meta:
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return '{} - {}'.format(self.device.name, self.name)
         return '{} - {}'.format(self.device.name, self.name)
@@ -960,6 +986,11 @@ class InventoryItem(ComponentModel):
         max_length=50,
         max_length=50,
         verbose_name='Name'
         verbose_name='Name'
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     manufacturer = models.ForeignKey(
     manufacturer = models.ForeignKey(
         to='dcim.Manufacturer',
         to='dcim.Manufacturer',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -997,8 +1028,8 @@ class InventoryItem(ComponentModel):
     ]
     ]
 
 
     class Meta:
     class Meta:
-        ordering = ['device__id', 'parent__id', 'name']
-        unique_together = ['device', 'parent', 'name']
+        ordering = ('device__id', 'parent__id', '_name')
+        unique_together = ('device', 'parent', 'name')
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name

+ 23 - 3
netbox/dcim/tables.py

@@ -200,6 +200,11 @@ def get_component_template_actions(model_name):
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
             </a>
         {{% endif %}}
         {{% endif %}}
+        {{% if perms.dcim.delete_{model_name} %}}
+            <a href="{{% url 'dcim:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-xs btn-danger">
+                <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
+            </a>
+        {{% endif %}}
     """.format(model_name=model_name).strip()
     """.format(model_name=model_name).strip()
 
 
 
 
@@ -229,7 +234,7 @@ class RegionTable(BaseTable):
 
 
 class SiteTable(BaseTable):
 class SiteTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3'))
+    name = tables.LinkColumn(order_by=('_name',))
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -291,7 +296,7 @@ class RackRoleTable(BaseTable):
 
 
 class RackTable(BaseTable):
 class RackTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
-    name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3'))
+    name = tables.LinkColumn(order_by=('_name',))
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -409,6 +414,7 @@ class DeviceTypeTable(BaseTable):
 
 
 class ConsolePortTemplateTable(BaseTable):
 class ConsolePortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('consoleporttemplate'),
         template_code=get_component_template_actions('consoleporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
         attrs={'td': {'class': 'text-right noprint'}},
@@ -432,6 +438,7 @@ class ConsolePortImportTable(BaseTable):
 
 
 class ConsoleServerPortTemplateTable(BaseTable):
 class ConsoleServerPortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('consoleserverporttemplate'),
         template_code=get_component_template_actions('consoleserverporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
         attrs={'td': {'class': 'text-right noprint'}},
@@ -455,6 +462,7 @@ class ConsoleServerPortImportTable(BaseTable):
 
 
 class PowerPortTemplateTable(BaseTable):
 class PowerPortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('powerporttemplate'),
         template_code=get_component_template_actions('powerporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
         attrs={'td': {'class': 'text-right noprint'}},
@@ -478,6 +486,7 @@ class PowerPortImportTable(BaseTable):
 
 
 class PowerOutletTemplateTable(BaseTable):
 class PowerOutletTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('poweroutlettemplate'),
         template_code=get_component_template_actions('poweroutlettemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
         attrs={'td': {'class': 'text-right noprint'}},
@@ -526,6 +535,7 @@ class InterfaceImportTable(BaseTable):
 
 
 class FrontPortTemplateTable(BaseTable):
 class FrontPortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     rear_port_position = tables.Column(
     rear_port_position = tables.Column(
         verbose_name='Position'
         verbose_name='Position'
     )
     )
@@ -552,6 +562,7 @@ class FrontPortImportTable(BaseTable):
 
 
 class RearPortTemplateTable(BaseTable):
 class RearPortTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('rearporttemplate'),
         template_code=get_component_template_actions('rearporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
         attrs={'td': {'class': 'text-right noprint'}},
@@ -575,6 +586,7 @@ class RearPortImportTable(BaseTable):
 
 
 class DeviceBayTemplateTable(BaseTable):
 class DeviceBayTemplateTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('devicebaytemplate'),
         template_code=get_component_template_actions('devicebaytemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
         attrs={'td': {'class': 'text-right noprint'}},
@@ -654,7 +666,7 @@ class PlatformTable(BaseTable):
 class DeviceTable(BaseTable):
 class DeviceTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.TemplateColumn(
     name = tables.TemplateColumn(
-        order_by=('_nat1', '_nat2', '_nat3'),
+        order_by=('_name',),
         template_code=DEVICE_LINK
         template_code=DEVICE_LINK
     )
     )
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
@@ -704,6 +716,7 @@ class DeviceImportTable(BaseTable):
 
 
 class DeviceComponentDetailTable(BaseTable):
 class DeviceComponentDetailTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     cable = tables.LinkColumn()
     cable = tables.LinkColumn()
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
@@ -713,6 +726,7 @@ class DeviceComponentDetailTable(BaseTable):
 
 
 
 
 class ConsolePortTable(BaseTable):
 class ConsolePortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ConsolePort
         model = ConsolePort
@@ -727,6 +741,7 @@ class ConsolePortDetailTable(DeviceComponentDetailTable):
 
 
 
 
 class ConsoleServerPortTable(BaseTable):
 class ConsoleServerPortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
@@ -741,6 +756,7 @@ class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
 
 
 
 
 class PowerPortTable(BaseTable):
 class PowerPortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerPort
         model = PowerPort
@@ -755,6 +771,7 @@ class PowerPortDetailTable(DeviceComponentDetailTable):
 
 
 
 
 class PowerOutletTable(BaseTable):
 class PowerOutletTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerOutlet
         model = PowerOutlet
@@ -786,6 +803,7 @@ class InterfaceDetailTable(DeviceComponentDetailTable):
 
 
 
 
 class FrontPortTable(BaseTable):
 class FrontPortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = FrontPort
         model = FrontPort
@@ -801,6 +819,7 @@ class FrontPortDetailTable(DeviceComponentDetailTable):
 
 
 
 
 class RearPortTable(BaseTable):
 class RearPortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = RearPort
         model = RearPort
@@ -816,6 +835,7 @@ class RearPortDetailTable(DeviceComponentDetailTable):
 
 
 
 
 class DeviceBayTable(BaseTable):
 class DeviceBayTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = DeviceBay
         model = DeviceBay

+ 12 - 10
netbox/dcim/tests/test_views.py

@@ -535,7 +535,6 @@ class ConsolePortTemplateTestCase(StandardTestCases.Views):
     test_get_object = None
     test_get_object = None
     test_list_objects = None
     test_list_objects = None
     test_create_object = None
     test_create_object = None
-    test_delete_object = None
     test_import_objects = None
     test_import_objects = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
@@ -580,7 +579,6 @@ class ConsoleServerPortTemplateTestCase(StandardTestCases.Views):
     test_get_object = None
     test_get_object = None
     test_list_objects = None
     test_list_objects = None
     test_create_object = None
     test_create_object = None
-    test_delete_object = None
     test_import_objects = None
     test_import_objects = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
@@ -625,7 +623,6 @@ class PowerPortTemplateTestCase(StandardTestCases.Views):
     test_get_object = None
     test_get_object = None
     test_list_objects = None
     test_list_objects = None
     test_create_object = None
     test_create_object = None
-    test_delete_object = None
     test_import_objects = None
     test_import_objects = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
@@ -676,7 +673,6 @@ class PowerOutletTemplateTestCase(StandardTestCases.Views):
     test_get_object = None
     test_get_object = None
     test_list_objects = None
     test_list_objects = None
     test_create_object = None
     test_create_object = None
-    test_delete_object = None
     test_import_objects = None
     test_import_objects = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
@@ -727,7 +723,6 @@ class InterfaceTemplateTestCase(StandardTestCases.Views):
     test_get_object = None
     test_get_object = None
     test_list_objects = None
     test_list_objects = None
     test_create_object = None
     test_create_object = None
-    test_delete_object = None
     test_import_objects = None
     test_import_objects = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
@@ -775,7 +770,6 @@ class FrontPortTemplateTestCase(StandardTestCases.Views):
     test_get_object = None
     test_get_object = None
     test_list_objects = None
     test_list_objects = None
     test_create_object = None
     test_create_object = None
-    test_delete_object = None
     test_import_objects = None
     test_import_objects = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
@@ -831,7 +825,6 @@ class RearPortTemplateTestCase(StandardTestCases.Views):
     test_get_object = None
     test_get_object = None
     test_list_objects = None
     test_list_objects = None
     test_create_object = None
     test_create_object = None
-    test_delete_object = None
     test_import_objects = None
     test_import_objects = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
@@ -878,7 +871,6 @@ class DeviceBayTemplateTestCase(StandardTestCases.Views):
     test_get_object = None
     test_get_object = None
     test_list_objects = None
     test_list_objects = None
     test_create_object = None
     test_create_object = None
-    test_delete_object = None
     test_import_objects = None
     test_import_objects = None
     test_bulk_edit_objects = None
     test_bulk_edit_objects = None
 
 
@@ -1070,7 +1062,6 @@ class ConsolePortTestCase(StandardTestCases.Views):
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
     test_create_object = None
     test_create_object = None
-    test_bulk_edit_objects = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
         return self._test_bulk_create_objects(expected_count=3)
         return self._test_bulk_create_objects(expected_count=3)
@@ -1101,6 +1092,11 @@ class ConsolePortTestCase(StandardTestCases.Views):
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
         }
         }
 
 
+        cls.bulk_edit_data = {
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+            'description': 'New description',
+        }
+
         cls.csv_data = (
         cls.csv_data = (
             "device,name",
             "device,name",
             "Device 1,Console Port 4",
             "Device 1,Console Port 4",
@@ -1164,7 +1160,6 @@ class PowerPortTestCase(StandardTestCases.Views):
 
 
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
-    test_bulk_edit_objects = None
     test_create_object = None
     test_create_object = None
 
 
     def test_bulk_create_objects(self):
     def test_bulk_create_objects(self):
@@ -1200,6 +1195,13 @@ class PowerPortTestCase(StandardTestCases.Views):
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
         }
         }
 
 
+        cls.bulk_edit_data = {
+            'type': PowerPortTypeChoices.TYPE_IEC_C14,
+            'maximum_draw': 100,
+            'allocated_draw': 50,
+            'description': 'New description',
+        }
+
         cls.csv_data = (
         cls.csv_data = (
             "device,name",
             "device,name",
             "Device 1,Power Port 4",
             "Device 1,Power Port 4",

+ 12 - 2
netbox/dcim/urls.py

@@ -95,48 +95,56 @@ urlpatterns = [
     path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
     path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
     path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
     path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
     path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
     path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
+    path('console-port-templates/<int:pk>/delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
 
 
     # Console server port templates
     # Console server port templates
     path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'),
     path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'),
     path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'),
     path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'),
     path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
     path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
     path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
     path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
+    path('console-server-port-templates/<int:pk>/delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
 
 
     # Power port templates
     # Power port templates
     path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'),
     path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'),
     path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'),
     path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'),
     path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
     path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
     path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
     path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
+    path('power-port-templates/<int:pk>/delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
 
 
     # Power outlet templates
     # Power outlet templates
     path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'),
     path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'),
     path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'),
     path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'),
     path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
     path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
     path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
     path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
+    path('power-outlet-templates/<int:pk>/delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
 
 
     # Interface templates
     # Interface templates
     path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'),
     path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'),
     path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
     path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
     path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
     path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
     path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
     path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
+    path('interface-templates/<int:pk>/delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
 
 
     # Front port templates
     # Front port templates
     path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'),
     path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'),
     path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'),
     path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'),
     path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
     path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
     path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
     path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
+    path('front-port-templates/<int:pk>/delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
 
 
     # Rear port templates
     # Rear port templates
     path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'),
     path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'),
     path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'),
     path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'),
     path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
     path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
     path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
     path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
+    path('rear-port-templates/<int:pk>/delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
 
 
     # Device bay templates
     # Device bay templates
     path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
     path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
     # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
     # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
     path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
     path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
     path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
     path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
+    path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
 
 
     # Device roles
     # Device roles
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
@@ -178,7 +186,8 @@ urlpatterns = [
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
     path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
     path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
-    # TODO: Bulk edit, rename, disconnect views for ConsolePorts
+    path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'),
+    # TODO: Bulk rename, disconnect views for ConsolePorts
     path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
     path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
     path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
@@ -204,7 +213,8 @@ urlpatterns = [
     path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
     path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
     path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
     path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
     path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
     path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
-    # TODO: Bulk edit, rename, disconnect views for PowerPorts
+    path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'),
+    # TODO: Bulk rename, disconnect views for PowerPorts
     path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
     path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
     path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
     path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),

+ 91 - 9
netbox/dcim/views.py

@@ -700,7 +700,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 
 
 #
 #
-# Device type components
+# Console port templates
 #
 #
 
 
 class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -717,6 +717,11 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.ConsolePortTemplateForm
     model_form = forms.ConsolePortTemplateForm
 
 
 
 
+class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_consoleporttemplate'
+    model = ConsolePortTemplate
+
+
 class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_consoleporttemplate'
     permission_required = 'dcim.change_consoleporttemplate'
     queryset = ConsolePortTemplate.objects.all()
     queryset = ConsolePortTemplate.objects.all()
@@ -730,6 +735,10 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     table = tables.ConsolePortTemplateTable
     table = tables.ConsolePortTemplateTable
 
 
 
 
+#
+# Console server port templates
+#
+
 class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverporttemplate'
     permission_required = 'dcim.add_consoleserverporttemplate'
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
@@ -744,6 +753,11 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView)
     model_form = forms.ConsoleServerPortTemplateForm
     model_form = forms.ConsoleServerPortTemplateForm
 
 
 
 
+class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_consoleserverporttemplate'
+    model = ConsoleServerPortTemplate
+
+
 class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_consoleserverporttemplate'
     permission_required = 'dcim.change_consoleserverporttemplate'
     queryset = ConsoleServerPortTemplate.objects.all()
     queryset = ConsoleServerPortTemplate.objects.all()
@@ -757,6 +771,10 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
     table = tables.ConsoleServerPortTemplateTable
     table = tables.ConsoleServerPortTemplateTable
 
 
 
 
+#
+# Power port templates
+#
+
 class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerporttemplate'
     permission_required = 'dcim.add_powerporttemplate'
     model = PowerPortTemplate
     model = PowerPortTemplate
@@ -771,6 +789,11 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.PowerPortTemplateForm
     model_form = forms.PowerPortTemplateForm
 
 
 
 
+class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_powerporttemplate'
+    model = PowerPortTemplate
+
+
 class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_powerporttemplate'
     permission_required = 'dcim.change_powerporttemplate'
     queryset = PowerPortTemplate.objects.all()
     queryset = PowerPortTemplate.objects.all()
@@ -784,6 +807,10 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     table = tables.PowerPortTemplateTable
     table = tables.PowerPortTemplateTable
 
 
 
 
+#
+# Power outlet templates
+#
+
 class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlettemplate'
     permission_required = 'dcim.add_poweroutlettemplate'
     model = PowerOutletTemplate
     model = PowerOutletTemplate
@@ -798,6 +825,11 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.PowerOutletTemplateForm
     model_form = forms.PowerOutletTemplateForm
 
 
 
 
+class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_poweroutlettemplate'
+    model = PowerOutletTemplate
+
+
 class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_poweroutlettemplate'
     permission_required = 'dcim.change_poweroutlettemplate'
     queryset = PowerOutletTemplate.objects.all()
     queryset = PowerOutletTemplate.objects.all()
@@ -811,6 +843,10 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
     table = tables.PowerOutletTemplateTable
     table = tables.PowerOutletTemplateTable
 
 
 
 
+#
+# Interface templates
+#
+
 class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interfacetemplate'
     permission_required = 'dcim.add_interfacetemplate'
     model = InterfaceTemplate
     model = InterfaceTemplate
@@ -825,6 +861,11 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.InterfaceTemplateForm
     model_form = forms.InterfaceTemplateForm
 
 
 
 
+class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_interfacetemplate'
+    model = InterfaceTemplate
+
+
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interfacetemplate'
     permission_required = 'dcim.change_interfacetemplate'
     queryset = InterfaceTemplate.objects.all()
     queryset = InterfaceTemplate.objects.all()
@@ -838,6 +879,10 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     table = tables.InterfaceTemplateTable
     table = tables.InterfaceTemplateTable
 
 
 
 
+#
+# Front port templates
+#
+
 class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_frontporttemplate'
     permission_required = 'dcim.add_frontporttemplate'
     model = FrontPortTemplate
     model = FrontPortTemplate
@@ -852,6 +897,11 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.FrontPortTemplateForm
     model_form = forms.FrontPortTemplateForm
 
 
 
 
+class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_frontporttemplate'
+    model = FrontPortTemplate
+
+
 class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_frontporttemplate'
     permission_required = 'dcim.change_frontporttemplate'
     queryset = FrontPortTemplate.objects.all()
     queryset = FrontPortTemplate.objects.all()
@@ -865,6 +915,10 @@ class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     table = tables.FrontPortTemplateTable
     table = tables.FrontPortTemplateTable
 
 
 
 
+#
+# Rear port templates
+#
+
 class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_rearporttemplate'
     permission_required = 'dcim.add_rearporttemplate'
     model = RearPortTemplate
     model = RearPortTemplate
@@ -879,6 +933,11 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.RearPortTemplateForm
     model_form = forms.RearPortTemplateForm
 
 
 
 
+class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_rearporttemplate'
+    model = RearPortTemplate
+
+
 class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rearporttemplate'
     permission_required = 'dcim.change_rearporttemplate'
     queryset = RearPortTemplate.objects.all()
     queryset = RearPortTemplate.objects.all()
@@ -892,6 +951,10 @@ class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     table = tables.RearPortTemplateTable
     table = tables.RearPortTemplateTable
 
 
 
 
+#
+# Device bay templates
+#
+
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebaytemplate'
     permission_required = 'dcim.add_devicebaytemplate'
     model = DeviceBayTemplate
     model = DeviceBayTemplate
@@ -906,6 +969,11 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.DeviceBayTemplateForm
     model_form = forms.DeviceBayTemplateForm
 
 
 
 
+class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_devicebaytemplate'
+    model = DeviceBayTemplate
+
+
 # class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 # class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 #     permission_required = 'dcim.change_devicebaytemplate'
 #     permission_required = 'dcim.change_devicebaytemplate'
 #     queryset = DeviceBayTemplate.objects.all()
 #     queryset = DeviceBayTemplate.objects.all()
@@ -1224,7 +1292,7 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsolePortFilterSet
     filterset = filters.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortDetailTable
     table = tables.ConsolePortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/consoleport_list.html'
 
 
 
 
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1253,6 +1321,13 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
     default_return_url = 'dcim:consoleport_list'
     default_return_url = 'dcim:consoleport_list'
 
 
 
 
+class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_consoleport'
+    queryset = ConsolePort.objects.all()
+    table = tables.ConsolePortTable
+    form = forms.ConsolePortBulkEditForm
+
+
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleport'
     permission_required = 'dcim.delete_consoleport'
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
@@ -1270,7 +1345,7 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsoleServerPortFilterSet
     filterset = filters.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortDetailTable
     table = tables.ConsoleServerPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/consoleserverport_list.html'
 
 
 
 
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1335,7 +1410,7 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerPortFilterSet
     filterset = filters.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortDetailTable
     table = tables.PowerPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/powerport_list.html'
 
 
 
 
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1364,6 +1439,13 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
     default_return_url = 'dcim:powerport_list'
     default_return_url = 'dcim:powerport_list'
 
 
 
 
+class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_powerport'
+    queryset = PowerPort.objects.all()
+    table = tables.PowerPortTable
+    form = forms.PowerPortBulkEditForm
+
+
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerport'
     permission_required = 'dcim.delete_powerport'
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
@@ -1381,7 +1463,7 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerOutletFilterSet
     filterset = filters.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletDetailTable
     table = tables.PowerOutletDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/poweroutlet_list.html'
 
 
 
 
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1446,7 +1528,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.InterfaceFilterSet
     filterset = filters.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceDetailTable
     table = tables.InterfaceDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/interface_list.html'
 
 
 
 
 class InterfaceView(PermissionRequiredMixin, View):
 class InterfaceView(PermissionRequiredMixin, View):
@@ -1548,7 +1630,7 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.FrontPortFilterSet
     filterset = filters.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortDetailTable
     table = tables.FrontPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/frontport_list.html'
 
 
 
 
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1613,7 +1695,7 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RearPortFilterSet
     filterset = filters.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortDetailTable
     table = tables.RearPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/rearport_list.html'
 
 
 
 
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1680,7 +1762,7 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.DeviceBayFilterSet
     filterset = filters.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayDetailTable
     table = tables.DeviceBayDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/devicebay_list.html'
 
 
 
 
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):

+ 83 - 42
netbox/extras/forms.py

@@ -1,14 +1,15 @@
 from django import forms
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from mptt.forms import TreeNodeMultipleChoiceField
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2,
-    BOOLEAN_WITH_BLANK_CHOICES,
+    CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
+    StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
@@ -190,7 +191,61 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
 #
 #
 
 
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):
-    tags = forms.ModelMultipleChoiceField(
+    regions = TreeNodeMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        widget=StaticSelect2Multiple()
+    )
+    sites = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/"
+        )
+    )
+    roles = DynamicModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/device-roles/"
+        )
+    )
+    platforms = DynamicModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/platforms/"
+        )
+    )
+    cluster_groups = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/cluster-groups/"
+        )
+    )
+    clusters = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/clusters/"
+        )
+    )
+    tenant_groups = DynamicModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenant-groups/"
+        )
+    )
+    tenants = DynamicModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/"
+        )
+    )
+    tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -204,36 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
-        fields = [
+        fields = (
             'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
             'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
             'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
             'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
-        ]
-        widgets = {
-            'regions': APISelectMultiple(
-                api_url="/api/dcim/regions/"
-            ),
-            'sites': APISelectMultiple(
-                api_url="/api/dcim/sites/"
-            ),
-            'roles': APISelectMultiple(
-                api_url="/api/dcim/device-roles/"
-            ),
-            'platforms': APISelectMultiple(
-                api_url="/api/dcim/platforms/"
-            ),
-            'cluster_groups': APISelectMultiple(
-                api_url="/api/virtualization/cluster-groups/"
-            ),
-            'clusters': APISelectMultiple(
-                api_url="/api/virtualization/clusters/"
-            ),
-            'tenant_groups': APISelectMultiple(
-                api_url="/api/tenancy/tenant-groups/"
-            ),
-            'tenants': APISelectMultiple(
-                api_url="/api/tenancy/tenants/"
-            ),
-        }
+        )
 
 
 
 
 class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
 class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -265,72 +294,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/regions/",
             api_url="/api/dcim/regions/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/device-roles/",
             api_url="/api/dcim/device-roles/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    platform = FilterChoiceField(
+    platform = DynamicModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/platforms/",
             api_url="/api/dcim/platforms/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    cluster_group = FilterChoiceField(
+    cluster_group = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/virtualization/cluster-groups/",
             api_url="/api/virtualization/cluster-groups/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    cluster_id = FilterChoiceField(
+    cluster_id = DynamicModelMultipleChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
+        required=False,
         label='Cluster',
         label='Cluster',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/virtualization/clusters/",
             api_url="/api/virtualization/clusters/",
         )
         )
     )
     )
-    tenant_group = FilterChoiceField(
+    tenant_group = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/tenancy/tenant-groups/",
             api_url="/api/tenancy/tenant-groups/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    tenant = FilterChoiceField(
+    tenant = DynamicModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/tenancy/tenants/",
             api_url="/api/tenancy/tenants/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    tag = FilterChoiceField(
+    tag = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/extras/tags/",
             api_url="/api/extras/tags/",
             value_field="slug",
             value_field="slug",
@@ -387,11 +425,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
     )
     )
     action = forms.ChoiceField(
     action = forms.ChoiceField(
         choices=add_blank_choice(ObjectChangeActionChoices),
         choices=add_blank_choice(ObjectChangeActionChoices),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     )
+    # TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users
     user = forms.ModelChoiceField(
     user = forms.ModelChoiceField(
         queryset=User.objects.order_by('username'),
         queryset=User.objects.order_by('username'),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     )
     changed_object_type = forms.ModelChoiceField(
     changed_object_type = forms.ModelChoiceField(
         queryset=ContentType.objects.order_by('model'),
         queryset=ContentType.objects.order_by('model'),

+ 2 - 2
netbox/ipam/api/serializers.py

@@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=IPAddressStatusChoices, required=False)
     status = ChoiceField(choices=IPAddressStatusChoices, required=False)
-    role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True)
+    role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
@@ -240,7 +240,7 @@ class AvailableIPSerializer(serializers.Serializer):
 class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
     device = NestedDeviceSerializer(required=False, allow_null=True)
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
-    protocol = ChoiceField(choices=ServiceProtocolChoices)
+    protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
     ipaddresses = SerializedPKRelatedField(
     ipaddresses = SerializedPKRelatedField(
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
         serializer=NestedIPAddressSerializer,
         serializer=NestedIPAddressSerializer,

+ 8 - 6
netbox/ipam/filters.py

@@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
 from tenancy.filters import TenancyFilterSet
 from tenancy.filters import TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
-    MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
+    MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
 )
 )
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from .choices import *
 from .choices import *
@@ -304,12 +304,12 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF
         to_field_name='rd',
         to_field_name='rd',
         label='VRF (RD)',
         label='VRF (RD)',
     )
     )
-    device = django_filters.CharFilter(
+    device = MultiValueCharFilter(
         method='filter_device',
         method='filter_device',
         field_name='name',
         field_name='name',
-        label='Device',
+        label='Device (name)',
     )
     )
-    device_id = django_filters.NumberFilter(
+    device_id = MultiValueNumberFilter(
         method='filter_device',
         method='filter_device',
         field_name='pk',
         field_name='pk',
         label='Device (ID)',
         label='Device (ID)',
@@ -385,8 +385,10 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
         try:
         try:
-            device = Device.objects.prefetch_related('device_type').get(**{name: value})
-            vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
+            devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
+            vc_interface_ids = []
+            for device in devices:
+                vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
             return queryset.filter(interface_id__in=vc_interface_ids)
             return queryset.filter(interface_id__in=vc_interface_ids)
         except Device.DoesNotExist:
         except Device.DoesNotExist:
             return queryset.none()
             return queryset.none()

+ 97 - 90
netbox/ipam/forms.py

@@ -10,9 +10,10 @@ from extras.forms import (
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
-    CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm,
-    SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES
+    add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField,
+    DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
+    FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from .constants import *
 from .constants import *
@@ -75,7 +76,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -148,6 +149,12 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
 #
 #
 
 
 class AggregateForm(BootstrapMixin, CustomFieldModelForm):
 class AggregateForm(BootstrapMixin, CustomFieldModelForm):
+    rir = DynamicModelChoiceField(
+        queryset=RIR.objects.all(),
+        widget=APISelect(
+            api_url="/api/ipam/rirs/"
+        )
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -162,9 +169,6 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
             'rir': "Regional Internet Registry responsible for this prefix",
             'rir': "Regional Internet Registry responsible for this prefix",
         }
         }
         widgets = {
         widgets = {
-            'rir': APISelect(
-                api_url="/api/ipam/rirs/"
-            ),
             'date_added': DatePicker(),
             'date_added': DatePicker(),
         }
         }
 
 
@@ -189,7 +193,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         queryset=Aggregate.objects.all(),
         queryset=Aggregate.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    rir = forms.ModelChoiceField(
+    rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         required=False,
         required=False,
         label='RIR',
         label='RIR',
@@ -226,9 +230,10 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
         label='Address family',
         label='Address family',
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    rir = FilterChoiceField(
+    rir = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         label='RIR',
         label='RIR',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/rirs/",
             api_url="/api/ipam/rirs/",
@@ -268,10 +273,16 @@ class RoleCSVForm(forms.ModelForm):
 #
 #
 
 
 class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    site = forms.ModelChoiceField(
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/",
+        )
+    )
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
-        label='Site',
         widget=APISelect(
         widget=APISelect(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             filter_for={
             filter_for={
@@ -283,11 +294,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             }
             }
         )
         )
     )
     )
-    vlan_group = ChainedModelChoiceField(
+    vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
         required=False,
         required=False,
         label='VLAN group',
         label='VLAN group',
         widget=APISelect(
         widget=APISelect(
@@ -300,12 +308,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             }
             }
         )
         )
     )
     )
-    vlan = ChainedModelChoiceField(
+    vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
-        chains=(
-            ('site', 'site'),
-            ('group', 'vlan_group'),
-        ),
         required=False,
         required=False,
         label='VLAN',
         label='VLAN',
         widget=APISelect(
         widget=APISelect(
@@ -313,6 +317,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             display_field='display_name'
             display_field='display_name'
         )
         )
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/roles/"
+        )
+    )
     tags = TagField(required=False)
     tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
@@ -322,13 +333,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             'tags',
             'tags',
         ]
         ]
         widgets = {
         widgets = {
-            'vrf': APISelect(
-                api_url="/api/ipam/vrfs/"
-            ),
             'status': StaticSelect2(),
             'status': StaticSelect2(),
-            'role': APISelect(
-                api_url="/api/ipam/roles/"
-            )
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -439,14 +444,14 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         queryset=Prefix.objects.all(),
         queryset=Prefix.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    site = forms.ModelChoiceField(
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/dcim/sites/"
             api_url="/api/dcim/sites/"
         )
         )
     )
     )
-    vrf = forms.ModelChoiceField(
+    vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
         label='VRF',
         label='VRF',
@@ -459,7 +464,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         max_value=PREFIX_LENGTH_MAX,
         max_value=PREFIX_LENGTH_MAX,
         required=False
         required=False
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -471,7 +476,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    role = forms.ModelChoiceField(
+    role = DynamicModelChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -525,10 +530,10 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         label='Mask length',
         label='Mask length',
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    vrf_id = FilterChoiceField(
+    vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        required=False,
         label='VRF',
         label='VRF',
-        null_label='-- Global --',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vrfs/",
             api_url="/api/ipam/vrfs/",
             null_option=True,
             null_option=True,
@@ -539,7 +544,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -551,20 +556,20 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/roles/",
             api_url="/api/ipam/roles/",
             value_field="slug",
             value_field="slug",
@@ -594,7 +599,15 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False
         required=False
     )
     )
-    nat_site = forms.ModelChoiceField(
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF',
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/"
+        )
+    )
+    nat_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         label='Site',
         label='Site',
@@ -606,11 +619,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
             }
             }
         )
         )
     )
     )
-    nat_rack = ChainedModelChoiceField(
+    nat_rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
-        chains=(
-            ('site', 'nat_site'),
-        ),
         required=False,
         required=False,
         label='Rack',
         label='Rack',
         widget=APISelect(
         widget=APISelect(
@@ -624,12 +634,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
             }
             }
         )
         )
     )
     )
-    nat_device = ChainedModelChoiceField(
+    nat_device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
-        chains=(
-            ('site', 'nat_site'),
-            ('rack', 'nat_rack'),
-        ),
         required=False,
         required=False,
         label='Device',
         label='Device',
         widget=APISelect(
         widget=APISelect(
@@ -651,11 +657,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
             }
             }
         )
         )
     )
     )
-    nat_inside = ChainedModelChoiceField(
+    nat_inside = DynamicModelChoiceField(
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
-        chains=(
-            ('interface__device', 'nat_device'),
-        ),
         required=False,
         required=False,
         label='IP Address',
         label='IP Address',
         widget=APISelect(
         widget=APISelect(
@@ -680,9 +683,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
             'role': StaticSelect2(),
             'role': StaticSelect2(),
-            'vrf': APISelect(
-                api_url="/api/ipam/vrfs/"
-            )
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -757,6 +757,14 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
 
 
 
 
 class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF',
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/"
+        )
+    )
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -766,9 +774,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
             'role': StaticSelect2(),
             'role': StaticSelect2(),
-            'vrf': APISelect(
-                api_url="/api/ipam/vrfs/"
-            )
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -904,7 +909,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    vrf = forms.ModelChoiceField(
+    vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
         label='VRF',
         label='VRF',
@@ -917,7 +922,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         max_value=IPADDRESS_MASK_LENGTH_MAX,
         max_value=IPADDRESS_MASK_LENGTH_MAX,
         required=False
         required=False
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -950,7 +955,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
 
 
 
 
 class IPAddressAssignForm(BootstrapMixin, forms.Form):
 class IPAddressAssignForm(BootstrapMixin, forms.Form):
-    vrf_id = forms.ModelChoiceField(
+    vrf_id = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
         label='VRF',
         label='VRF',
@@ -996,10 +1001,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
         label='Mask length',
         label='Mask length',
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    vrf_id = FilterChoiceField(
+    vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        required=False,
         label='VRF',
         label='VRF',
-        null_label='-- Global --',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vrfs/",
             api_url="/api/ipam/vrfs/",
             null_option=True,
             null_option=True,
@@ -1030,6 +1035,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
 #
 #
 
 
 class VLANGroupForm(BootstrapMixin, forms.ModelForm):
 class VLANGroupForm(BootstrapMixin, forms.ModelForm):
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/sites/"
+        )
+    )
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -1037,11 +1049,6 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
         fields = [
         fields = [
             'site', 'name', 'slug',
             'site', 'name', 'slug',
         ]
         ]
-        widgets = {
-            'site': APISelect(
-                api_url="/api/dcim/sites/"
-            )
-        }
 
 
 
 
 class VLANGroupCSVForm(forms.ModelForm):
 class VLANGroupCSVForm(forms.ModelForm):
@@ -1065,7 +1072,7 @@ class VLANGroupCSVForm(forms.ModelForm):
 
 
 
 
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -1077,10 +1084,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- Global --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
@@ -1094,7 +1101,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 #
 #
 
 
 class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    site = forms.ModelChoiceField(
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -1107,17 +1114,20 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             }
             }
         )
         )
     )
     )
-    group = ChainedModelChoiceField(
+    group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
         required=False,
         required=False,
-        label='Group',
         widget=APISelect(
         widget=APISelect(
             api_url='/api/ipam/vlan-groups/',
             api_url='/api/ipam/vlan-groups/',
         )
         )
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/roles/"
+        )
+    )
     tags = TagField(required=False)
     tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
@@ -1135,9 +1145,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
         }
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
-            'role': APISelect(
-                api_url="/api/ipam/roles/"
-            )
         }
         }
 
 
 
 
@@ -1212,21 +1219,21 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    site = forms.ModelChoiceField(
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/dcim/sites/"
             api_url="/api/dcim/sites/"
         )
         )
     )
     )
-    group = forms.ModelChoiceField(
+    group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/ipam/vlan-groups/"
             api_url="/api/ipam/vlan-groups/"
         )
         )
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -1238,7 +1245,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    role = forms.ModelChoiceField(
+    role = DynamicModelChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -1263,7 +1270,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -1276,20 +1283,20 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- Global --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    group_id = FilterChoiceField(
+    group_id = DynamicModelMultipleChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
+        required=False,
         label='VLAN group',
         label='VLAN group',
-        null_label='-- None --',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vlan-groups/",
             api_url="/api/ipam/vlan-groups/",
             null_option=True,
             null_option=True,
@@ -1300,10 +1307,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/roles/",
             api_url="/api/ipam/roles/",
             value_field="slug",
             value_field="slug",

+ 5 - 6
netbox/ipam/tests/test_filters.py

@@ -392,13 +392,12 @@ class IPAddressTestCase(TestCase):
         params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
         params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
 
-    # TODO: Test for multiple values
     def test_device(self):
     def test_device(self):
-        device = Device.objects.first()
-        params = {'device_id': device.pk}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-        params = {'device': device.name}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        devices = Device.objects.all()[:2]
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device': [devices[0].name, devices[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_virtual_machine(self):
     def test_virtual_machine(self):
         vms = VirtualMachine.objects.all()[:2]
         vms = VirtualMachine.objects.all()[:2]

+ 6 - 7
netbox/project-static/js/forms.js

@@ -220,19 +220,19 @@ $(document).ready(function() {
                     }
                     }
 
 
                     if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) {
                     if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) {
-                        results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }
+                        results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] };
                         results[record.site.name + ":" + record.group.name].children.push(record);
                         results[record.site.name + ":" + record.group.name].children.push(record);
                     }
                     }
                     else if( record.group !== undefined && record.group !== null ) {
                     else if( record.group !== undefined && record.group !== null ) {
-                        results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }
+                        results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] };
                         results[record.group.name].children.push(record);
                         results[record.group.name].children.push(record);
                     }
                     }
                     else if( record.site !== undefined && record.site !== null ) {
                     else if( record.site !== undefined && record.site !== null ) {
-                        results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }
+                        results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] };
                         results[record.site.name].children.push(record);
                         results[record.site.name].children.push(record);
                     }
                     }
                     else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) {
                     else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) {
-                        results['global'] = results['global'] || { text: 'Global', children: [] }
+                        results['global'] = results['global'] || { text: 'Global', children: [] };
                         results['global'].children.push(record);
                         results['global'].children.push(record);
                     }
                     }
                     else {
                     else {
@@ -246,10 +246,9 @@ $(document).ready(function() {
 
 
                 // Handle the null option, but only add it once
                 // Handle the null option, but only add it once
                 if (element.getAttribute('data-null-option') && data.previous === null) {
                 if (element.getAttribute('data-null-option') && data.previous === null) {
-                    var null_option = $(element).children()[0];
                     results.unshift({
                     results.unshift({
-                        id: null_option.value,
-                        text: null_option.text
+                        id: 'null',
+                        text: 'None'
                     });
                     });
                 }
                 }
 
 

+ 11 - 9
netbox/secrets/forms.py

@@ -8,8 +8,8 @@ from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
 )
 )
 from utilities.forms import (
 from utilities.forms import (
-    APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField,
-    StaticSelect2Multiple, TagFilterField
+    APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
 )
 )
 from .constants import *
 from .constants import *
 from .models import Secret, SecretRole, UserKey
 from .models import Secret, SecretRole, UserKey
@@ -87,6 +87,12 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
         label='Plaintext (verify)',
         label='Plaintext (verify)',
         widget=forms.PasswordInput()
         widget=forms.PasswordInput()
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=SecretRole.objects.all(),
+        widget=APISelect(
+            api_url="/api/secrets/secret-roles/"
+        )
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -96,11 +102,6 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
         fields = [
         fields = [
             'role', 'name', 'plaintext', 'plaintext2', 'tags',
             'role', 'name', 'plaintext', 'plaintext2', 'tags',
         ]
         ]
-        widgets = {
-            'role': APISelect(
-                api_url="/api/secrets/secret-roles/"
-            )
-        }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -157,7 +158,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         queryset=Secret.objects.all(),
         queryset=Secret.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    role = forms.ModelChoiceField(
+    role = DynamicModelChoiceField(
         queryset=SecretRole.objects.all(),
         queryset=SecretRole.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -181,9 +182,10 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=SecretRole.objects.all(),
         queryset=SecretRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=True,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/secrets/secret-roles/",
             api_url="/api/secrets/secret-roles/",
             value_field="slug",
             value_field="slug",

+ 2 - 4
netbox/templates/dcim/device_component_list.html → netbox/templates/dcim/consoleport_list.html

@@ -1,16 +1,14 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load helpers %}
 
 
 {% block content %}
 {% block content %}
 <div class="pull-right noprint">
 <div class="pull-right noprint">
     {% export_button content_type %}
     {% export_button content_type %}
 </div>
 </div>
-<h1>{% block title %}{{ table.Meta.model|model_name|capfirst }}s{% endblock %}</h1>
+<h1>{% block title %}Console Ports{% endblock %}</h1>
 <div class="row">
 <div class="row">
 	<div class="col-md-9">
 	<div class="col-md-9">
-        {% include 'responsive_table.html' %}
-        {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleport_bulk_edit' bulk_delete_url='dcim:consoleport_bulk_delete' %}
     </div>
     </div>
     <div class="col-md-3 noprint">
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
 		{% include 'inc/search_panel.html' %}

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

@@ -0,0 +1,17 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Console Server Ports{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleserverport_bulk_edit' bulk_delete_url='dcim:consoleserverport_bulk_delete' %}
+    </div>
+    <div class="col-md-3 noprint">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

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

@@ -0,0 +1,17 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Device Bays{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicebay_bulk_delete' %}
+    </div>
+    <div class="col-md-3 noprint">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

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

@@ -0,0 +1,17 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Front Ports{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:frontport_bulk_edit' bulk_delete_url='dcim:frontport_bulk_delete' %}
+    </div>
+    <div class="col-md-3 noprint">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

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

@@ -0,0 +1,17 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Interfaces{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:interface_bulk_edit' bulk_delete_url='dcim:interface_bulk_delete' %}
+    </div>
+    <div class="col-md-3 noprint">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

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

@@ -0,0 +1,17 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Power Outlets{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:poweroutlet_bulk_edit' bulk_delete_url='dcim:poweroutlet_bulk_delete' %}
+    </div>
+    <div class="col-md-3 noprint">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

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

@@ -0,0 +1,17 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Power Ports{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerport_bulk_edit' bulk_delete_url='dcim:powerport_bulk_delete' %}
+    </div>
+    <div class="col-md-3 noprint">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

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

@@ -0,0 +1,17 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+<div class="pull-right noprint">
+    {% export_button content_type %}
+</div>
+<h1>{% block title %}Rear Ports{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rearport_bulk_edit' bulk_delete_url='dcim:rearport_bulk_delete' %}
+    </div>
+    <div class="col-md-3 noprint">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 6 - 1
netbox/templates/inc/nav_menu.html

@@ -239,7 +239,7 @@
                                     <a href="{% url 'dcim:poweroutlet_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
                                     <a href="{% url 'dcim:poweroutlet_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
                                 </div>
                                 </div>
                             {% endif %}
                             {% endif %}
-                            <a href="{% url 'dcim:poweroutlet_list' %}">Power Outlet</a>
+                            <a href="{% url 'dcim:poweroutlet_list' %}">Power Outlets</a>
                         </li>
                         </li>
                         <li{% if not perms.dcim.view_devicebay %} class="disabled"{% endif %}>
                         <li{% if not perms.dcim.view_devicebay %} class="disabled"{% endif %}>
                             {% if perms.dcim.add_devicebay %}
                             {% if perms.dcim.add_devicebay %}
@@ -479,6 +479,11 @@
                         <li class="dropdown-header">Miscellaneous</li>
                         <li class="dropdown-header">Miscellaneous</li>
                         <li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
                         <li{% if not perms.extras.view_configcontext %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
+                            {% if perms.extras.add_configcontext %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'extras:configcontext_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
+                                </div>
+                            {% endif %}
                         </li>
                         </li>
                         <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
                         <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
                             <a href="{% url 'extras:script_list' %}">Scripts</a>
                             <a href="{% url 'extras:script_list' %}">Scripts</a>

+ 22 - 23
netbox/tenancy/forms.py

@@ -2,11 +2,11 @@ from django import forms
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from extras.forms import (
 from extras.forms import (
-    AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm,
+    AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm,
 )
 )
 from utilities.forms import (
 from utilities.forms import (
-    APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
-    FilterChoiceField, SlugField, TagFilterField
+    APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField, SlugField, TagFilterField,
 )
 )
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 
 
@@ -42,6 +42,13 @@ class TenantGroupCSVForm(forms.ModelForm):
 
 
 class TenantForm(BootstrapMixin, CustomFieldModelForm):
 class TenantForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
+    group = DynamicModelChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/tenancy/tenant-groups/"
+        )
+    )
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
         required=False
         required=False
@@ -49,14 +56,9 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm):
 
 
     class Meta:
     class Meta:
         model = Tenant
         model = Tenant
-        fields = [
+        fields = (
             'name', 'slug', 'group', 'description', 'comments', 'tags',
             'name', 'slug', 'group', 'description', 'comments', 'tags',
-        ]
-        widgets = {
-            'group': APISelect(
-                api_url="/api/tenancy/tenant-groups/"
-            )
-        }
+        )
 
 
 
 
 class TenantCSVForm(CustomFieldModelForm):
 class TenantCSVForm(CustomFieldModelForm):
@@ -85,7 +87,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    group = forms.ModelChoiceField(
+    group = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -105,10 +107,10 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    group = FilterChoiceField(
+    group = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/tenancy/tenant-groups/",
             api_url="/api/tenancy/tenant-groups/",
             value_field="slug",
             value_field="slug",
@@ -122,8 +124,8 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Form extensions
 # Form extensions
 #
 #
 
 
-class TenancyForm(ChainedFieldsMixin, forms.Form):
-    tenant_group = forms.ModelChoiceField(
+class TenancyForm(forms.Form):
+    tenant_group = DynamicModelChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -136,11 +138,8 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
             }
             }
         )
         )
     )
     )
-    tenant = ChainedModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
-        chains=(
-            ('group', 'tenant_group'),
-        ),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url='/api/tenancy/tenants/'
             api_url='/api/tenancy/tenants/'
@@ -160,10 +159,10 @@ class TenancyForm(ChainedFieldsMixin, forms.Form):
 
 
 
 
 class TenancyFilterForm(forms.Form):
 class TenancyFilterForm(forms.Form):
-    tenant_group = FilterChoiceField(
+    tenant_group = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/tenancy/tenant-groups/",
             api_url="/api/tenancy/tenant-groups/",
             value_field="slug",
             value_field="slug",
@@ -173,10 +172,10 @@ class TenancyFilterForm(forms.Form):
             }
             }
         )
         )
     )
     )
-    tenant = FilterChoiceField(
+    tenant = DynamicModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/tenancy/tenants/",
             api_url="/api/tenancy/tenants/",
             value_field="slug",
             value_field="slug",

+ 19 - 2
netbox/utilities/api.py

@@ -61,10 +61,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
 
 
 class ChoiceField(Field):
 class ChoiceField(Field):
     """
     """
-    Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
+    Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. Accepts a single value on write.
+
+    :param choices: An iterable of choices in the form (value, key).
+    :param allow_blank: Allow blank values in addition to the listed choices.
     """
     """
-    def __init__(self, choices, **kwargs):
+    def __init__(self, choices, allow_blank=False, **kwargs):
         self.choiceset = choices
         self.choiceset = choices
+        self.allow_blank = allow_blank
         self._choices = dict()
         self._choices = dict()
 
 
         # Unpack grouped choices
         # Unpack grouped choices
@@ -77,6 +81,15 @@ class ChoiceField(Field):
 
 
         super().__init__(**kwargs)
         super().__init__(**kwargs)
 
 
+    def validate_empty_values(self, data):
+        # Convert null to an empty string unless allow_null == True
+        if data is None:
+            if self.allow_null:
+                return True, None
+            else:
+                data = ''
+        return super().validate_empty_values(data)
+
     def to_representation(self, obj):
     def to_representation(self, obj):
         if obj is '':
         if obj is '':
             return None
             return None
@@ -93,6 +106,10 @@ class ChoiceField(Field):
         return data
         return data
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
+        if data is '':
+            if self.allow_blank:
+                return data
+            raise ValidationError("This field may not be blank.")
 
 
         # Provide an explicit error message if the request is trying to write a dict or list
         # Provide an explicit error message if the request is trying to write a dict or list
         if isinstance(data, (dict, list)):
         if isinstance(data, (dict, list)):

+ 33 - 0
netbox/utilities/fields.py

@@ -1,6 +1,7 @@
 from django.core.validators import RegexValidator
 from django.core.validators import RegexValidator
 from django.db import models
 from django.db import models
 
 
+from utilities.ordering import naturalize
 from .forms import ColorSelect
 from .forms import ColorSelect
 
 
 ColorValidator = RegexValidator(
 ColorValidator = RegexValidator(
@@ -35,3 +36,35 @@ class ColorField(models.CharField):
     def formfield(self, **kwargs):
     def formfield(self, **kwargs):
         kwargs['widget'] = ColorSelect
         kwargs['widget'] = ColorSelect
         return super().formfield(**kwargs)
         return super().formfield(**kwargs)
+
+
+class NaturalOrderingField(models.CharField):
+    """
+    A field which stores a naturalized representation of its target field, to be used for ordering its parent model.
+
+    :param target_field: Name of the field of the parent model to be naturalized
+    :param naturalize_function: The function used to generate a naturalized value (optional)
+    """
+    description = "Stores a representation of its target field suitable for natural ordering"
+
+    def __init__(self, target_field, naturalize_function=naturalize, *args, **kwargs):
+        self.target_field = target_field
+        self.naturalize_function = naturalize_function
+        super().__init__(*args, **kwargs)
+
+    def pre_save(self, model_instance, add):
+        """
+        Generate a naturalized value from the target field
+        """
+        value = getattr(model_instance, self.target_field)
+        return self.naturalize_function(value, max_length=self.max_length)
+
+    def deconstruct(self):
+        kwargs = super().deconstruct()[3]  # Pass kwargs from CharField
+        kwargs['naturalize_function'] = self.naturalize_function
+        return (
+            self.name,
+            'utilities.fields.NaturalOrderingField',
+            ['target_field'],
+            kwargs,
+        )

+ 26 - 105
netbox/utilities/forms.py

@@ -8,7 +8,7 @@ from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
 from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
 from django.db.models import Count
 from django.db.models import Count
-from mptt.forms import TreeNodeMultipleChoiceField
+from django.forms import BoundField
 
 
 from .choices import unpack_grouped_choices
 from .choices import unpack_grouped_choices
 from .constants import *
 from .constants import *
@@ -211,7 +211,7 @@ class SelectWithPK(StaticSelect2):
     option_template_name = 'widgets/select_option_with_pk.html'
     option_template_name = 'widgets/select_option_with_pk.html'
 
 
 
 
-class ContentTypeSelect(forms.Select):
+class ContentTypeSelect(StaticSelect2):
     """
     """
     Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
     Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
         <option value="37" api-value="console-server-port">console server port</option>
         <option value="37" api-value="console-server-port">console server port</option>
@@ -259,9 +259,6 @@ class APISelect(SelectWithDisabled):
         name of the query param and the value if the query param's value.
         name of the query param and the value if the query param's value.
     :param null_option: If true, include the static null option in the selection list.
     :param null_option: If true, include the static null option in the selection list.
     """
     """
-    # Only preload the selected option(s); new options are dynamically displayed and added via the API
-    template_name = 'widgets/select_api.html'
-
     def __init__(
     def __init__(
         self,
         self,
         api_url,
         api_url,
@@ -525,34 +522,6 @@ class FlexibleModelChoiceField(forms.ModelChoiceField):
         return value
         return value
 
 
 
 
-class ChainedModelChoiceField(forms.ModelChoiceField):
-    """
-    A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary
-    mapping of model fields to peer fields within the form. For example:
-
-        country1 = forms.ModelChoiceField(queryset=Country.objects.all())
-        city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'}
-
-    The queryset of the `city1` field will be modified as
-
-        .filter(country=<value>)
-
-    where <value> is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.)
-    """
-    def __init__(self, chains=None, *args, **kwargs):
-        self.chains = chains
-        super().__init__(*args, **kwargs)
-
-
-class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
-    """
-    See ChainedModelChoiceField
-    """
-    def __init__(self, chains=None, *args, **kwargs):
-        self.chains = chains
-        super().__init__(*args, **kwargs)
-
-
 class SlugField(forms.SlugField):
 class SlugField(forms.SlugField):
     """
     """
     Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
     Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
@@ -581,46 +550,38 @@ class TagFilterField(forms.MultipleChoiceField):
         super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
         super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs)
 
 
 
 
-class FilterChoiceIterator(forms.models.ModelChoiceIterator):
-
-    def __iter__(self):
-        # Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string)
-        if self.field.null_label is not None:
-            yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label)
-        queryset = self.queryset.all()
-        # Can't use iterator() when queryset uses prefetch_related()
-        if not queryset._prefetch_related_lookups:
-            queryset = queryset.iterator()
-        for obj in queryset:
-            yield self.choice(obj)
-
+class DynamicModelChoiceMixin:
+    field_modifier = ''
 
 
-class FilterChoiceFieldMixin(object):
-    iterator = FilterChoiceIterator
+    def get_bound_field(self, form, field_name):
+        bound_field = BoundField(form, self, field_name)
 
 
-    def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs):
-        self.null_label = null_label
-        self.count_attr = count_attr
-        if 'required' not in kwargs:
-            kwargs['required'] = False
-        if 'widget' not in kwargs:
-            kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6})
-        super().__init__(*args, **kwargs)
+        # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
+        # will be populated on-demand via the APISelect widget.
+        field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier)
+        if bound_field.data:
+            self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.data)})
+        elif bound_field.initial:
+            self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.initial)})
+        else:
+            self.queryset = self.queryset.none()
 
 
-    def label_from_instance(self, obj):
-        label = super().label_from_instance(obj)
-        obj_count = getattr(obj, self.count_attr, None)
-        if obj_count is not None:
-            return '{} ({})'.format(label, obj_count)
-        return label
+        return bound_field
 
 
 
 
-class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField):
+class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField):
+    """
+    Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
+    rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
+    """
     pass
     pass
 
 
 
 
-class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField):
-    pass
+class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField):
+    """
+    A multiple-choice version of DynamicModelChoiceField.
+    """
+    field_modifier = '__in'
 
 
 
 
 class LaxURLField(forms.URLField):
 class LaxURLField(forms.URLField):
@@ -675,46 +636,6 @@ class BootstrapMixin(forms.BaseForm):
                 field.widget.attrs['placeholder'] = field.label
                 field.widget.attrs['placeholder'] = field.label
 
 
 
 
-class ChainedFieldsMixin(forms.BaseForm):
-    """
-    Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields.
-    """
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        for field_name, field in self.fields.items():
-
-            if isinstance(field, ChainedModelChoiceField):
-
-                filters_dict = {}
-                for (db_field, parent_field) in field.chains:
-                    if self.is_bound and parent_field in self.data and self.data[parent_field]:
-                        filters_dict[db_field] = self.data[parent_field] or None
-                    elif self.initial.get(parent_field):
-                        filters_dict[db_field] = self.initial[parent_field]
-                    elif self.fields[parent_field].widget.attrs.get('nullable'):
-                        filters_dict[db_field] = None
-                    else:
-                        break
-
-                # Limit field queryset by chained field values
-                if filters_dict:
-                    field.queryset = field.queryset.filter(**filters_dict)
-                # Editing an existing instance; limit field to its current value
-                elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name):
-                    obj = getattr(self.instance, field_name)
-                    if obj is not None:
-                        field.queryset = field.queryset.filter(pk=obj.pk)
-                    else:
-                        field.queryset = field.queryset.none()
-                # Creating a new instance with no bound data; nullify queryset
-                elif not self.data.get(field_name):
-                    field.queryset = field.queryset.none()
-                # Creating a new instance with bound data; limit queryset to the specified value
-                else:
-                    field.queryset = field.queryset.filter(pk=self.data.get(field_name))
-
-
 class ReturnURLForm(forms.Form):
 class ReturnURLForm(forms.Form):
     """
     """
     Provides a hidden return URL field to control where the user is directed after the form is submitted.
     Provides a hidden return URL field to control where the user is directed after the form is submitted.

+ 0 - 45
netbox/utilities/managers.py

@@ -1,45 +0,0 @@
-from django.db.models import Manager
-from django.db.models.expressions import RawSQL
-
-NAT1 = r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)"
-NAT2 = r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')"
-NAT3 = r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)"
-
-
-class NaturalOrderingManager(Manager):
-    """
-    Order objects naturally by a designated field (defaults to 'name'). Leading and/or trailing digits of values within
-    this field will be cast as independent integers and sorted accordingly. For example, "Foo2" will be ordered before
-    "Foo10", even though the digit 1 is normally ordered before the digit 2.
-    """
-    natural_order_field = 'name'
-
-    def get_queryset(self):
-
-        queryset = super().get_queryset()
-
-        db_table = self.model._meta.db_table
-        db_field = self.natural_order_field
-
-        # Append the three subfields derived from the designated natural ordering field
-        queryset = (
-            queryset.annotate(_nat1=RawSQL(NAT1.format(db_table, db_field), ()))
-            .annotate(_nat2=RawSQL(NAT2.format(db_table, db_field), ()))
-            .annotate(_nat3=RawSQL(NAT3.format(db_table, db_field), ()))
-        )
-
-        # Replace any instance of the designated natural ordering field with its three subfields
-        ordering = []
-        for field in self.model._meta.ordering:
-            if field == self.natural_order_field:
-                ordering.append('_nat1')
-                ordering.append('_nat2')
-                ordering.append('_nat3')
-            else:
-                ordering.append(field)
-
-        # Default to using the _nat indexes if Meta.ordering is empty
-        if not ordering:
-            ordering = ('_nat1', '_nat2', '_nat3')
-
-        return queryset.order_by(*ordering)

+ 80 - 0
netbox/utilities/ordering.py

@@ -0,0 +1,80 @@
+import re
+
+INTERFACE_NAME_REGEX = r'(^(?P<type>[^\d\.:]+)?)' \
+                       r'((?P<slot>\d+)/)?' \
+                       r'((?P<subslot>\d+)/)?' \
+                       r'((?P<position>\d+)/)?' \
+                       r'((?P<subposition>\d+)/)?' \
+                       r'((?P<id>\d+))?' \
+                       r'(:(?P<channel>\d+))?' \
+                       r'(.(?P<vc>\d+)$)?'
+
+
+def naturalize(value, max_length=None, integer_places=8):
+    """
+    Take an alphanumeric string and prepend all integers to `integer_places` places to ensure the strings
+    are ordered naturally. For example:
+
+        site9router21
+        site10router4
+        site10router19
+
+    becomes:
+
+        site00000009router00000021
+        site00000010router00000004
+        site00000010router00000019
+
+    :param value: The value to be naturalized
+    :param max_length: The maximum length of the returned string. Characters beyond this length will be stripped.
+    :param integer_places: The number of places to which each integer will be expanded. (Default: 8)
+    """
+    if not value:
+        return value
+    output = []
+    for segment in re.split(r'(\d+)', value):
+        if segment.isdigit():
+            output.append(segment.rjust(integer_places, '0'))
+        elif segment:
+            output.append(segment)
+    ret = ''.join(output)
+
+    return ret[:max_length] if max_length else ret
+
+
+def naturalize_interface(value, max_length=None):
+    """
+    Similar in nature to naturalize(), but takes into account a particular naming format adapted from the old
+    InterfaceManager.
+
+    :param value: The value to be naturalized
+    :param max_length: The maximum length of the returned string. Characters beyond this length will be stripped.
+    """
+    output = []
+    match = re.search(INTERFACE_NAME_REGEX, value)
+    if match is None:
+        return value
+
+    # First, we order by slot/position, padding each to four digits. If a field is not present,
+    # set it to 9999 to ensure it is ordered last.
+    for part_name in ('slot', 'subslot', 'position', 'subposition'):
+        part = match.group(part_name)
+        if part is not None:
+            output.append(part.rjust(4, '0'))
+        else:
+            output.append('9999')
+
+    # Append the type, if any.
+    if match.group('type') is not None:
+        output.append(match.group('type'))
+
+    # Finally, append any remaining fields, left-padding to eight digits each.
+    for part_name in ('id', 'channel', 'vc'):
+        part = match.group(part_name)
+        if part is not None:
+            output.append(part.rjust(6, '0'))
+        else:
+            output.append('000000')
+
+    ret = ''.join(output)
+    return ret[:max_length] if max_length else ret

+ 0 - 9
netbox/utilities/templates/widgets/select_api.html

@@ -1,9 +0,0 @@
-<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
-{% for group_name, group_choices, group_index in widget.optgroups %}
-  {% if group_name %}<optgroup label="{{ group_name }}">{% endif %}
-  {% for option in group_choices %}
-    {% if option.attrs.selected or option.value == "null" %}{% include option.template_name with widget=option %}{% endif %}
-  {% endfor %}
-  {% if group_name %}</optgroup>{% endif %}
-{% endfor %}
-</select>

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

@@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
 class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
     virtual_machine = NestedVirtualMachineSerializer()
     type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
     type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
-    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),

+ 82 - 78
netbox/virtualization/forms.py

@@ -14,9 +14,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ConfirmationForm,
-    CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2,
-    StaticSelect2Multiple, TagFilterField,
+    CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField,
 )
 )
 from .choices import *
 from .choices import *
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -77,6 +76,26 @@ class ClusterGroupCSVForm(forms.ModelForm):
 #
 #
 
 
 class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    type = DynamicModelChoiceField(
+        queryset=ClusterType.objects.all(),
+        widget=APISelect(
+            api_url="/api/virtualization/cluster-types/"
+        )
+    )
+    group = DynamicModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/virtualization/cluster-groups/"
+        )
+    )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/sites/"
+        )
+    )
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
         required=False
         required=False
@@ -84,20 +103,9 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = [
+        fields = (
             'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags',
             'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags',
-        ]
-        widgets = {
-            'type': APISelect(
-                api_url="/api/virtualization/cluster-types/"
-            ),
-            'group': APISelect(
-                api_url="/api/virtualization/cluster-groups/"
-            ),
-            'site': APISelect(
-                api_url="/api/dcim/sites/"
-            ),
-        }
+        )
 
 
 
 
 class ClusterCSVForm(CustomFieldModelCSVForm):
 class ClusterCSVForm(CustomFieldModelCSVForm):
@@ -147,25 +155,28 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    type = forms.ModelChoiceField(
+    type = DynamicModelChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/virtualization/cluster-types/"
             api_url="/api/virtualization/cluster-types/"
         )
         )
     )
     )
-    group = forms.ModelChoiceField(
+    group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/virtualization/cluster-groups/"
             api_url="/api/virtualization/cluster-groups/"
         )
         )
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
-        required=False
+        required=False,
+        widget=APISelect(
+            api_url="/api/tenancy/tenants/"
+        )
     )
     )
-    site = forms.ModelChoiceField(
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -189,7 +200,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant'
         'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant'
     ]
     ]
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
-    type = FilterChoiceField(
+    type = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -198,7 +209,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
             value_field='slug',
             value_field='slug',
         )
         )
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -210,10 +221,9 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
         required=False,
         required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
@@ -221,10 +231,9 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    group = FilterChoiceField(
+    group = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
         required=False,
         required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/virtualization/cluster-groups/",
             api_url="/api/virtualization/cluster-groups/",
@@ -235,8 +244,8 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
-class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
-    region = forms.ModelChoiceField(
+class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
+    region = DynamicModelChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -249,11 +258,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
             }
             }
         )
         )
     )
     )
-    site = ChainedModelChoiceField(
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        chains=(
-            ('region', 'region'),
-        ),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url='/api/dcim/sites/',
             api_url='/api/dcim/sites/',
@@ -263,11 +269,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
             }
             }
         )
         )
     )
     )
-    rack = ChainedModelChoiceField(
+    rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url='/api/dcim/racks/',
             api_url='/api/dcim/racks/',
@@ -279,12 +282,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
             }
             }
         )
         )
     )
     )
-    devices = ChainedModelMultipleChoiceField(
+    devices = DynamicModelMultipleChoiceField(
         queryset=Device.objects.filter(cluster__isnull=True),
         queryset=Device.objects.filter(cluster__isnull=True),
-        chains=(
-            ('site', 'site'),
-            ('rack', 'rack'),
-        ),
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/dcim/devices/',
             api_url='/api/dcim/devices/',
             display_field='display_name',
             display_field='display_name',
@@ -331,7 +330,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
 #
 #
 
 
 class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
-    cluster_group = forms.ModelChoiceField(
+    cluster_group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -344,15 +343,28 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             }
             }
         )
         )
     )
     )
-    cluster = ChainedModelChoiceField(
+    cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
-        chains=(
-            ('group', 'cluster_group'),
-        ),
         widget=APISelect(
         widget=APISelect(
             api_url='/api/virtualization/clusters/'
             api_url='/api/virtualization/clusters/'
         )
         )
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=DeviceRole.objects.all(),
+        widget=APISelect(
+            api_url="/api/dcim/device-roles/",
+            additional_query_params={
+                "vm_role": "True"
+            }
+        )
+    )
+    platform = DynamicModelChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/platforms/'
+        )
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -373,17 +385,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
         }
         }
         widgets = {
         widgets = {
             "status": StaticSelect2(),
             "status": StaticSelect2(),
-            "role": APISelect(
-                api_url="/api/dcim/device-roles/",
-                additional_query_params={
-                    "vm_role": "True"
-                }
-            ),
             'primary_ip4': StaticSelect2(),
             'primary_ip4': StaticSelect2(),
             'primary_ip6': StaticSelect2(),
             'primary_ip6': StaticSelect2(),
-            'platform': APISelect(
-                api_url='/api/dcim/platforms/'
-            )
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -493,14 +496,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
         initial='',
         initial='',
         widget=StaticSelect2(),
         widget=StaticSelect2(),
     )
     )
-    cluster = forms.ModelChoiceField(
+    cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url='/api/virtualization/clusters/'
             api_url='/api/virtualization/clusters/'
         )
         )
     )
     )
-    role = forms.ModelChoiceField(
+    role = DynamicModelChoiceField(
         queryset=DeviceRole.objects.filter(
         queryset=DeviceRole.objects.filter(
             vm_role=True
             vm_role=True
         ),
         ),
@@ -512,14 +515,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
             }
             }
         )
         )
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url='/api/tenancy/tenants/'
             api_url='/api/tenancy/tenants/'
         )
         )
     )
     )
-    platform = forms.ModelChoiceField(
+    platform = DynamicModelChoiceField(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -559,34 +562,35 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    cluster_group = FilterChoiceField(
+    cluster_group = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/virtualization/cluster-groups/',
             api_url='/api/virtualization/cluster-groups/',
             value_field="slug",
             value_field="slug",
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    cluster_type = FilterChoiceField(
+    cluster_type = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/virtualization/cluster-types/',
             api_url='/api/virtualization/cluster-types/',
             value_field="slug",
             value_field="slug",
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    cluster_id = FilterChoiceField(
+    cluster_id = DynamicModelMultipleChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
+        required=False,
         label='Cluster',
         label='Cluster',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/virtualization/clusters/',
             api_url='/api/virtualization/clusters/',
         )
         )
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -598,20 +602,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/dcim/sites/',
             api_url='/api/dcim/sites/',
             value_field="slug",
             value_field="slug",
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.filter(vm_role=True),
         queryset=DeviceRole.objects.filter(vm_role=True),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/dcim/device-roles/',
             api_url='/api/dcim/device-roles/',
             value_field="slug",
             value_field="slug",
@@ -626,10 +630,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    platform = FilterChoiceField(
+    platform = DynamicModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url='/api/dcim/platforms/',
             api_url='/api/dcim/platforms/',
             value_field="slug",
             value_field="slug",
@@ -648,7 +652,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
 #
 #
 
 
 class InterfaceForm(BootstrapMixin, forms.ModelForm):
 class InterfaceForm(BootstrapMixin, forms.ModelForm):
-    untagged_vlan = forms.ModelChoiceField(
+    untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -657,7 +661,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             full=True
             full=True
         )
         )
     )
     )
-    tagged_vlans = forms.ModelMultipleChoiceField(
+    tagged_vlans = DynamicModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
@@ -774,7 +778,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         widget=StaticSelect2(),
         widget=StaticSelect2(),
     )
     )
-    untagged_vlan = forms.ModelChoiceField(
+    untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -783,7 +787,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
             full=True
             full=True
         )
         )
     )
     )
-    tagged_vlans = forms.ModelMultipleChoiceField(
+    tagged_vlans = DynamicModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
@@ -862,7 +866,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    untagged_vlan = forms.ModelChoiceField(
+    untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -871,7 +875,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
             full=True
             full=True
         )
         )
     )
     )
-    tagged_vlans = forms.ModelMultipleChoiceField(
+    tagged_vlans = DynamicModelMultipleChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.