Просмотр исходного кода

Merge pull request #4165 from netbox-community/develop

Release v2.7.5
Jeremy Stretch 6 лет назад
Родитель
Сommit
120cbb0159
100 измененных файлов с 3965 добавлено и 1975 удалено
  1. 3 0
      .github/stale.yml
  2. 3 2
      docs/additional-features/custom-scripts.md
  3. 14 0
      docs/configuration/optional-settings.md
  4. 46 1
      docs/configuration/required-settings.md
  5. 10 2
      docs/development/style-guide.md
  6. 1 1
      docs/installation/index.md
  7. 1 1
      docs/installation/upgrading.md
  8. 35 0
      docs/release-notes/version-2.7.md
  9. 0 1
      mkdocs.yml
  10. 5 5
      netbox/circuits/api/urls.py
  11. 27 16
      netbox/circuits/forms.py
  12. 4 9
      netbox/circuits/tests/test_views.py
  13. 30 30
      netbox/circuits/urls.py
  14. 18 10
      netbox/dcim/api/serializers.py
  15. 37 37
      netbox/dcim/api/urls.py
  16. 2 0
      netbox/dcim/constants.py
  17. 347 136
      netbox/dcim/forms.py
  18. 1 55
      netbox/dcim/managers.py
  19. 147 0
      netbox/dcim/migrations/0093_device_component_ordering.py
  20. 138 0
      netbox/dcim/migrations/0094_device_component_template_ordering.py
  21. 70 0
      netbox/dcim/migrations/0095_primary_model_ordering.py
  22. 53 0
      netbox/dcim/migrations/0096_interface_ordering.py
  23. 51 29
      netbox/dcim/models/__init__.py
  24. 64 37
      netbox/dcim/models/device_component_templates.py
  25. 73 42
      netbox/dcim/models/device_components.py
  26. 25 4
      netbox/dcim/tables.py
  27. 482 151
      netbox/dcim/tests/test_views.py
  28. 269 248
      netbox/dcim/urls.py
  29. 154 69
      netbox/dcim/views.py
  30. 10 10
      netbox/extras/api/urls.py
  31. 85 43
      netbox/extras/forms.py
  32. 111 0
      netbox/extras/management/commands/renaturalize.py
  33. 7 2
      netbox/extras/scripts.py
  34. 3 3
      netbox/extras/tests/test_views.py
  35. 23 23
      netbox/extras/urls.py
  36. 2 2
      netbox/ipam/api/serializers.py
  37. 10 10
      netbox/ipam/api/urls.py
  38. 8 6
      netbox/ipam/filters.py
  39. 97 90
      netbox/ipam/forms.py
  40. 5 6
      netbox/ipam/tests/test_filters.py
  41. 176 0
      netbox/ipam/tests/test_ordering.py
  42. 10 25
      netbox/ipam/tests/test_views.py
  43. 76 76
      netbox/ipam/urls.py
  44. 8 1
      netbox/netbox/configuration.example.py
  45. 51 22
      netbox/netbox/settings.py
  46. 27 27
      netbox/netbox/urls.py
  47. 1 1
      netbox/netbox/views.py
  48. 12 0
      netbox/project-static/css/base.css
  49. 9 0
      netbox/project-static/css/rack_elevation.css
  50. 6 7
      netbox/project-static/js/forms.js
  51. 5 5
      netbox/secrets/api/urls.py
  52. 5 3
      netbox/secrets/api/views.py
  53. 11 9
      netbox/secrets/forms.py
  54. 36 11
      netbox/secrets/tests/test_api.py
  55. 3 8
      netbox/secrets/tests/test_views.py
  56. 14 14
      netbox/secrets/urls.py
  57. 2 4
      netbox/templates/dcim/consoleport_list.html
  58. 17 0
      netbox/templates/dcim/consoleserverport_list.html
  59. 49 29
      netbox/templates/dcim/device.html
  60. 2 11
      netbox/templates/dcim/device_component_add.html
  61. 1 1
      netbox/templates/dcim/device_inventory.html
  62. 17 0
      netbox/templates/dcim/devicebay_list.html
  63. 16 16
      netbox/templates/dcim/devicetype.html
  64. 17 0
      netbox/templates/dcim/frontport_list.html
  65. 3 3
      netbox/templates/dcim/inc/devicetype_component_table.html
  66. 0 6
      netbox/templates/dcim/inc/rack_elevation.html
  67. 17 0
      netbox/templates/dcim/interface_list.html
  68. 17 0
      netbox/templates/dcim/poweroutlet_list.html
  69. 17 0
      netbox/templates/dcim/powerport_list.html
  70. 17 0
      netbox/templates/dcim/rearport_list.html
  71. 17 17
      netbox/templates/home.html
  72. 4 4
      netbox/templates/inc/custom_fields_panel.html
  73. 6 1
      netbox/templates/inc/nav_menu.html
  74. 2 1
      netbox/templates/secrets/secret_edit.html
  75. 3 3
      netbox/templates/virtualization/virtualmachine.html
  76. 1 1
      netbox/templates/virtualization/virtualmachine_component_add.html
  77. 3 3
      netbox/tenancy/api/urls.py
  78. 22 23
      netbox/tenancy/forms.py
  79. 3 8
      netbox/tenancy/tests/test_views.py
  80. 15 15
      netbox/tenancy/urls.py
  81. 9 9
      netbox/users/urls.py
  82. 19 2
      netbox/utilities/api.py
  83. 33 0
      netbox/utilities/fields.py
  84. 35 125
      netbox/utilities/forms.py
  85. 0 45
      netbox/utilities/managers.py
  86. 80 0
      netbox/utilities/ordering.py
  87. 0 9
      netbox/utilities/templates/widgets/select_api.html
  88. 1 1
      netbox/utilities/templatetags/helpers.py
  89. 220 86
      netbox/utilities/testing/testcases.py
  90. 3 30
      netbox/utilities/testing/utils.py
  91. 43 0
      netbox/utilities/tests/test_ordering.py
  92. 13 0
      netbox/utilities/utils.py
  93. 30 50
      netbox/utilities/views.py
  94. 1 1
      netbox/virtualization/api/serializers.py
  95. 6 6
      netbox/virtualization/api/urls.py
  96. 8 2
      netbox/virtualization/choices.py
  97. 130 114
      netbox/virtualization/forms.py
  98. 6 3
      netbox/virtualization/models.py
  99. 99 16
      netbox/virtualization/tests/test_views.py
  100. 40 40
      netbox/virtualization/urls.py

+ 3 - 0
.github/stale.yml

@@ -1,5 +1,8 @@
 # Configuration for Stale (https://github.com/apps/stale)
 # Configuration for Stale (https://github.com/apps/stale)
 
 
+# Pull requests are exempt from being marked as stale
+only: issues
+
 # Number of days of inactivity before an issue becomes stale
 # Number of days of inactivity before an issue becomes stale
 daysUntilStale: 14
 daysUntilStale: 14
 
 

+ 3 - 2
docs/additional-features/custom-scripts.md

@@ -177,10 +177,11 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
 
 
 All variables support the following default options:
 All variables support the following default options:
 
 
-* `label` - The name of the form field
-* `description` - A brief description of the field
 * `default` - The field's default value
 * `default` - The field's default value
+* `description` - A brief description of the field
+* `label` - The name of the form field
 * `required` - Indicates whether the field is mandatory (default: true)
 * `required` - Indicates whether the field is mandatory (default: true)
+* `widget` - The class of form widget to use (see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/forms/widgets/))
 
 
 ## Example
 ## Example
 
 

+ 14 - 0
docs/configuration/optional-settings.md

@@ -109,6 +109,20 @@ In order to send email, NetBox needs an email server configured. The following i
 * TIMEOUT - Amount of time to wait for a connection (seconds)
 * TIMEOUT - Amount of time to wait for a connection (seconds)
 * FROM_EMAIL - Sender address for emails sent by NetBox
 * FROM_EMAIL - Sender address for emails sent by NetBox
 
 
+Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
+
+```
+# python ./manage.py nbshell
+>>> from django.core.mail import send_mail
+>>> send_mail(
+  'Test Email Subject',
+  'Test Email Body',
+  'noreply-netbox@example.com',
+  ['users@example.com'],
+  fail_silently=False
+)
+```
+
 ---
 ---
 
 
 ## EXEMPT_VIEW_PERMISSIONS
 ## EXEMPT_VIEW_PERMISSIONS

+ 46 - 1
docs/configuration/required-settings.md

@@ -21,7 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv
 * `PASSWORD` - PostgreSQL password
 * `PASSWORD` - PostgreSQL password
 * `HOST` - Name or IP address of the database server (use `localhost` if running locally)
 * `HOST` - Name or IP address of the database server (use `localhost` if running locally)
 * `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
 * `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
-* `CONN_MAX_AGE` - Number in seconds for Netbox to keep database connections open. 150-300 seconds is typically a good starting point ([more info](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections)).
+* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (150-300 is recommended)
 
 
 Example:
 Example:
 
 
@@ -36,6 +36,9 @@ DATABASE = {
 }
 }
 ```
 ```
 
 
+!!! note
+    NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases).
+
 ---
 ---
 
 
 ## REDIS
 ## REDIS
@@ -85,6 +88,48 @@ REDIS = {
     It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the
     It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the
     same Redis instance for both may result in webhook processing data being lost during cache flushing events.
     same Redis instance for both may result in webhook processing data being lost during cache flushing events.
 
 
+### Using Redis Sentinel
+
+If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal 
+configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from 
+above and the addition of two new keys.
+
+* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address 
+of the Redis server and port for each sentinel instance to connect to
+* `SENTINEL_SERVICE`: Name of the master / service to connect to
+
+Example:
+
+```python
+REDIS = {
+    'webhooks': {
+        'SENTINELS': [('mysentinel.redis.example.com', 6379)],
+        'SENTINEL_SERVICE': 'netbox',
+        'PASSWORD': '',
+        'DATABASE': 0,
+        'DEFAULT_TIMEOUT': 300,
+        'SSL': False,
+    },
+    'caching': {
+        'SENTINELS': [
+            ('mysentinel.redis.example.com', 6379),
+            ('othersentinel.redis.example.com', 6379)
+        ],
+        'SENTINEL_SERVICE': 'netbox',
+        'PASSWORD': '',
+        'DATABASE': 1,
+        'DEFAULT_TIMEOUT': 300,
+        'SSL': False,
+    }
+}
+```
+
+!!! note:
+    It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible
+    for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via 
+    `SENTINELS`/`SENTINEL_SERVICE`.
+
+
 ---
 ---
 
 
 ## SECRET_KEY
 ## SECRET_KEY

+ 10 - 2
docs/development/style-guide.md

@@ -32,7 +32,7 @@ pycodestyle --ignore=W504,E501 netbox/
 
 
 The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
 The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
 
 
-If there's a strong case for introducing a new depdency, it must meet the following criteria:
+If there's a strong case for introducing a new dependency, it must meet the following criteria:
 
 
 * Its complete source code must be published and freely accessible without registration.
 * Its complete source code must be published and freely accessible without registration.
 * Its license must be conducive to inclusion in an open source project.
 * Its license must be conducive to inclusion in an open source project.
@@ -45,10 +45,18 @@ When adding a new dependency, a short description of the package and the URL of
 
 
 * When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
 * When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
 
 
+* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
+
 * No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.
 * No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.
 
 
 * Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable.
 * Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable.
 
 
-* Every model should have a docstring. Every custom method should include an expalantion of its function.
+* Every model should have a docstring. Every custom method should include an explanation of its function.
 
 
 * Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.
 * Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.
+
+## Branding
+
+* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. But never "Netbox" or any other deviation.
+
+* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size.

+ 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

+ 35 - 0
docs/release-notes/version-2.7.md

@@ -1,3 +1,38 @@
+# v2.7.5 (2020-02-13)
+
+**Note:** This release includes several database schema migrations that calculate and store copies of names for certain objects to improve natural ordering performance (see [#3799](https://github.com/netbox-community/netbox/issues/3799)). These migrations may take a few minutes to run if you have a very large number of objects defined in NetBox.
+
+## Enhancements
+
+* [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable
+* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components
+* [#3984](https://github.com/netbox-community/netbox/issues/3984) - Add support for Redis Sentinel
+* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevations
+* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add more status choices for virtual machines
+* [#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
+* [#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
+
+* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IP addresses by multiple devices
+* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable on small screens
+* [#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
+* [#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
+* [#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
+* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Restore device bay counts in rack elevation diagrams
+* [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption
+* [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts
+* [#4159](https://github.com/netbox-community/netbox/issues/4159) - Fix implementation of Redis caching configuration
+
+---
+
 # v2.7.4 (2020-02-04)
 # v2.7.4 (2020-02-04)
 
 
 ## Enhancements
 ## Enhancements

+ 0 - 1
mkdocs.yml

@@ -41,7 +41,6 @@ pages:
         - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
         - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
         - Reports: 'additional-features/reports.md'
         - Reports: 'additional-features/reports.md'
         - Tags: 'additional-features/tags.md'
         - Tags: 'additional-features/tags.md'
-        - Topology Maps: 'additional-features/topology-maps.md'
         - Webhooks: 'additional-features/webhooks.md'
         - Webhooks: 'additional-features/webhooks.md'
     - Administration:
     - Administration:
         - Replicating NetBox: 'administration/replicating-netbox.md'
         - Replicating NetBox: 'administration/replicating-netbox.md'

+ 5 - 5
netbox/circuits/api/urls.py

@@ -15,15 +15,15 @@ router = routers.DefaultRouter()
 router.APIRootView = CircuitsRootView
 router.APIRootView = CircuitsRootView
 
 
 # Field choices
 # Field choices
-router.register(r'_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice')
 
 
 # Providers
 # Providers
-router.register(r'providers', views.ProviderViewSet)
+router.register('providers', views.ProviderViewSet)
 
 
 # Circuits
 # Circuits
-router.register(r'circuit-types', views.CircuitTypeViewSet)
-router.register(r'circuits', views.CircuitViewSet)
-router.register(r'circuit-terminations', views.CircuitTerminationViewSet)
+router.register('circuit-types', views.CircuitTypeViewSet)
+router.register('circuits', views.CircuitViewSet)
+router.register('circuit-terminations', views.CircuitTerminationViewSet)
 
 
 app_name = 'circuits-api'
 app_name = 'circuits-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 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",

+ 4 - 9
netbox/circuits/tests/test_views.py

@@ -2,10 +2,10 @@ import datetime
 
 
 from circuits.choices import *
 from circuits.choices import *
 from circuits.models import Circuit, CircuitType, Provider
 from circuits.models import Circuit, CircuitType, Provider
-from utilities.testing import StandardTestCases
+from utilities.testing import ViewTestCases
 
 
 
 
-class ProviderTestCase(StandardTestCases.Views):
+class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Provider
     model = Provider
 
 
     @classmethod
     @classmethod
@@ -46,14 +46,9 @@ class ProviderTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class CircuitTypeTestCase(StandardTestCases.Views):
+class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = CircuitType
     model = CircuitType
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -77,7 +72,7 @@ class CircuitTypeTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class CircuitTestCase(StandardTestCases.Views):
+class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Circuit
     model = Circuit
 
 
     @classmethod
     @classmethod

+ 30 - 30
netbox/circuits/urls.py

@@ -9,42 +9,42 @@ app_name = 'circuits'
 urlpatterns = [
 urlpatterns = [
 
 
     # Providers
     # Providers
-    path(r'providers/', views.ProviderListView.as_view(), name='provider_list'),
-    path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
-    path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
-    path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
-    path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
-    path(r'providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
-    path(r'providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
-    path(r'providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
-    path(r'providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
+    path('providers/', views.ProviderListView.as_view(), name='provider_list'),
+    path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
+    path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
+    path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
+    path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
+    path('providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
+    path('providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
+    path('providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
+    path('providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
 
 
     # Circuit types
     # Circuit types
-    path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
-    path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
-    path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
-    path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
-    path(r'circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
-    path(r'circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
+    path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
+    path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
+    path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
+    path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
+    path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
+    path('circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
 
 
     # Circuits
     # Circuits
-    path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'),
-    path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
-    path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
-    path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
-    path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
-    path(r'circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
-    path(r'circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
-    path(r'circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
-    path(r'circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
-    path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
+    path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
+    path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
+    path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
+    path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
+    path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
+    path('circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
+    path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
+    path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
+    path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
+    path('circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
 
 
     # Circuit terminations
     # Circuit terminations
 
 
-    path(r'circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
-    path(r'circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
-    path(r'circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
-    path(r'circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
-    path(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
+    path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
+    path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
+    path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
+    path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
+    path('circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
 
 
 ]
 ]

+ 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

+ 37 - 37
netbox/dcim/api/urls.py

@@ -15,65 +15,65 @@ router = routers.DefaultRouter()
 router.APIRootView = DCIMRootView
 router.APIRootView = DCIMRootView
 
 
 # Field choices
 # Field choices
-router.register(r'_choices', views.DCIMFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.DCIMFieldChoicesViewSet, basename='field-choice')
 
 
 # Sites
 # Sites
-router.register(r'regions', views.RegionViewSet)
-router.register(r'sites', views.SiteViewSet)
+router.register('regions', views.RegionViewSet)
+router.register('sites', views.SiteViewSet)
 
 
 # Racks
 # Racks
-router.register(r'rack-groups', views.RackGroupViewSet)
-router.register(r'rack-roles', views.RackRoleViewSet)
-router.register(r'racks', views.RackViewSet)
-router.register(r'rack-reservations', views.RackReservationViewSet)
+router.register('rack-groups', views.RackGroupViewSet)
+router.register('rack-roles', views.RackRoleViewSet)
+router.register('racks', views.RackViewSet)
+router.register('rack-reservations', views.RackReservationViewSet)
 
 
 # Device types
 # Device types
-router.register(r'manufacturers', views.ManufacturerViewSet)
-router.register(r'device-types', views.DeviceTypeViewSet)
+router.register('manufacturers', views.ManufacturerViewSet)
+router.register('device-types', views.DeviceTypeViewSet)
 
 
 # Device type components
 # Device type components
-router.register(r'console-port-templates', views.ConsolePortTemplateViewSet)
-router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
-router.register(r'power-port-templates', views.PowerPortTemplateViewSet)
-router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet)
-router.register(r'interface-templates', views.InterfaceTemplateViewSet)
-router.register(r'front-port-templates', views.FrontPortTemplateViewSet)
-router.register(r'rear-port-templates', views.RearPortTemplateViewSet)
-router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet)
+router.register('console-port-templates', views.ConsolePortTemplateViewSet)
+router.register('console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
+router.register('power-port-templates', views.PowerPortTemplateViewSet)
+router.register('power-outlet-templates', views.PowerOutletTemplateViewSet)
+router.register('interface-templates', views.InterfaceTemplateViewSet)
+router.register('front-port-templates', views.FrontPortTemplateViewSet)
+router.register('rear-port-templates', views.RearPortTemplateViewSet)
+router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
 
 
 # Devices
 # Devices
-router.register(r'device-roles', views.DeviceRoleViewSet)
-router.register(r'platforms', views.PlatformViewSet)
-router.register(r'devices', views.DeviceViewSet)
+router.register('device-roles', views.DeviceRoleViewSet)
+router.register('platforms', views.PlatformViewSet)
+router.register('devices', views.DeviceViewSet)
 
 
 # Device components
 # Device components
-router.register(r'console-ports', views.ConsolePortViewSet)
-router.register(r'console-server-ports', views.ConsoleServerPortViewSet)
-router.register(r'power-ports', views.PowerPortViewSet)
-router.register(r'power-outlets', views.PowerOutletViewSet)
-router.register(r'interfaces', views.InterfaceViewSet)
-router.register(r'front-ports', views.FrontPortViewSet)
-router.register(r'rear-ports', views.RearPortViewSet)
-router.register(r'device-bays', views.DeviceBayViewSet)
-router.register(r'inventory-items', views.InventoryItemViewSet)
+router.register('console-ports', views.ConsolePortViewSet)
+router.register('console-server-ports', views.ConsoleServerPortViewSet)
+router.register('power-ports', views.PowerPortViewSet)
+router.register('power-outlets', views.PowerOutletViewSet)
+router.register('interfaces', views.InterfaceViewSet)
+router.register('front-ports', views.FrontPortViewSet)
+router.register('rear-ports', views.RearPortViewSet)
+router.register('device-bays', views.DeviceBayViewSet)
+router.register('inventory-items', views.InventoryItemViewSet)
 
 
 # Connections
 # Connections
-router.register(r'console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections')
-router.register(r'power-connections', views.PowerConnectionViewSet, basename='powerconnections')
-router.register(r'interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections')
+router.register('console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections')
+router.register('power-connections', views.PowerConnectionViewSet, basename='powerconnections')
+router.register('interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections')
 
 
 # Cables
 # Cables
-router.register(r'cables', views.CableViewSet)
+router.register('cables', views.CableViewSet)
 
 
 # Virtual chassis
 # Virtual chassis
-router.register(r'virtual-chassis', views.VirtualChassisViewSet)
+router.register('virtual-chassis', views.VirtualChassisViewSet)
 
 
 # Power
 # Power
-router.register(r'power-panels', views.PowerPanelViewSet)
-router.register(r'power-feeds', views.PowerFeedViewSet)
+router.register('power-panels', views.PowerPanelViewSet)
+router.register('power-feeds', views.PowerFeedViewSet)
 
 
 # Miscellaneous
 # Miscellaneous
-router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
+router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 
 
 app_name = 'dcim-api'
 app_name = 'dcim-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 2 - 0
netbox/dcim/constants.py

@@ -9,6 +9,8 @@ from .choices import InterfaceTypeChoices
 
 
 RACK_U_HEIGHT_DEFAULT = 42
 RACK_U_HEIGHT_DEFAULT = 42
 
 
+RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
+
 RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
 RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
 
 

Разница между файлами не показана из-за своего большого размера
+ 347 - 136
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
+        ),
+    ]

+ 51 - 29
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
@@ -387,6 +389,10 @@ class RackElevationHelperMixin:
 
 
     @staticmethod
     @staticmethod
     def _draw_device_front(drawing, device, start, end, text):
     def _draw_device_front(drawing, device, start, end, text):
+        name = str(device)
+        if device.devicebay_count:
+            name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
+
         color = device.device_role.color
         color = device.device_role.color
         link = drawing.add(
         link = drawing.add(
             drawing.a(
             drawing.a(
@@ -401,7 +407,7 @@ class RackElevationHelperMixin:
         ))
         ))
         link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
         link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
         hex_color = '#{}'.format(foreground_color(color))
         hex_color = '#{}'.format(foreground_color(color))
-        link.add(drawing.text(str(device), insert=text, fill=hex_color))
+        link.add(drawing.text(str(name), insert=text, fill=hex_color))
 
 
     @staticmethod
     @staticmethod
     def _draw_device_rear(drawing, device, start, end, text):
     def _draw_device_rear(drawing, device, start, end, text):
@@ -431,11 +437,19 @@ class RackElevationHelperMixin:
         link.add(drawing.rect(start, end, class_=class_))
         link.add(drawing.rect(start, end, class_=class_))
         link.add(drawing.text("add device", insert=text, class_='add-device'))
         link.add(drawing.text("add device", insert=text, class_='add-device'))
 
 
-    def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height):
+    def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width):
 
 
-        drawing = self._setup_drawing(unit_width, unit_height * self.u_height)
+        drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height)
 
 
         unit_cursor = 0
         unit_cursor = 0
+        for ru in range(0, self.u_height):
+            start_y = ru * unit_height
+            position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2)
+            unit = ru + 1 if self.desc_units else self.u_height - ru
+            drawing.add(
+                drawing.text(str(unit), position_coordinates, class_="unit")
+            )
+
         for unit in elevation:
         for unit in elevation:
 
 
             # Loop through all units in the elevation
             # Loop through all units in the elevation
@@ -445,9 +459,9 @@ class RackElevationHelperMixin:
             # Setup drawing coordinates
             # Setup drawing coordinates
             start_y = unit_cursor * unit_height
             start_y = unit_cursor * unit_height
             end_y = unit_height * height
             end_y = unit_height * height
-            start_cordinates = (0, start_y)
-            end_cordinates = (unit_width, end_y)
-            text_cordinates = (unit_width / 2, start_y + end_y / 2)
+            start_cordinates = (legend_width, start_y)
+            end_cordinates = (legend_width + unit_width, end_y)
+            text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2)
 
 
             # Draw the device
             # Draw the device
             if device and device.face == face:
             if device and device.face == face:
@@ -469,7 +483,7 @@ class RackElevationHelperMixin:
             unit_cursor += height
             unit_cursor += height
 
 
         # Wrap the drawing with a border
         # Wrap the drawing with a border
-        drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack'))
+        drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack'))
 
 
         return drawing
         return drawing
 
 
@@ -492,7 +506,8 @@ class RackElevationHelperMixin:
             self,
             self,
             face=DeviceFaceChoices.FACE_FRONT,
             face=DeviceFaceChoices.FACE_FRONT,
             unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
             unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
-            unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
+            unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
+            legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
     ):
     ):
         """
         """
         Return an SVG of the rack elevation
         Return an SVG of the rack elevation
@@ -505,7 +520,7 @@ class RackElevationHelperMixin:
         elevation = self.merge_elevations(face)
         elevation = self.merge_elevations(face)
         reserved_units = self.get_reserved_units()
         reserved_units = self.get_reserved_units()
 
 
-        return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height)
+        return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width)
 
 
 
 
 class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
 class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
@@ -516,6 +531,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 +632,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 +652,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 +1331,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 +1431,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 +1452,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

+ 73 - 42
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,14 +1028,14 @@ 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
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):
-        return self.device.get_absolute_url()
+        return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk})
 
 
     def to_csv(self):
     def to_csv(self):
         return (
         return (

+ 25 - 4
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'}},
@@ -440,7 +447,7 @@ class ConsoleServerPortTemplateTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
-        fields = ('pk', 'name', 'actions')
+        fields = ('pk', 'name', 'type', 'actions')
         empty_text = "None"
         empty_text = "None"
 
 
 
 
@@ -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
@@ -777,6 +794,7 @@ class InterfaceTable(BaseTable):
 
 
 class InterfaceDetailTable(DeviceComponentDetailTable):
 class InterfaceDetailTable(DeviceComponentDetailTable):
     parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
     parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
+    name = tables.LinkColumn()
 
 
     class Meta(InterfaceTable.Meta):
     class Meta(InterfaceTable.Meta):
         order_by = ('parent', 'name')
         order_by = ('parent', 'name')
@@ -785,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
@@ -800,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
@@ -815,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

+ 482 - 151
netbox/dcim/tests/test_views.py

@@ -11,7 +11,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import VLAN
 from ipam.models import VLAN
-from utilities.testing import StandardTestCases
+from utilities.testing import ViewTestCases
 
 
 
 
 def create_test_device(name):
 def create_test_device(name):
@@ -27,14 +27,9 @@ def create_test_device(name):
     return device
     return device
 
 
 
 
-class RegionTestCase(StandardTestCases.Views):
+class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Region
     model = Region
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -61,7 +56,7 @@ class RegionTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class SiteTestCase(StandardTestCases.Views):
+class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Site
     model = Site
 
 
     @classmethod
     @classmethod
@@ -118,14 +113,9 @@ class SiteTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class RackGroupTestCase(StandardTestCases.Views):
+class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = RackGroup
     model = RackGroup
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -152,14 +142,9 @@ class RackGroupTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class RackRoleTestCase(StandardTestCases.Views):
+class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = RackRole
     model = RackRole
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -184,7 +169,7 @@ class RackRoleTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class RackReservationTestCase(StandardTestCases.Views):
+class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = RackReservation
     model = RackReservation
 
 
     # Disable inapplicable tests
     # Disable inapplicable tests
@@ -226,7 +211,7 @@ class RackReservationTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class RackTestCase(StandardTestCases.Views):
+class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Rack
     model = Rack
 
 
     @classmethod
     @classmethod
@@ -302,14 +287,9 @@ class RackTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class ManufacturerTestCase(StandardTestCases.Views):
+class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Manufacturer
     model = Manufacturer
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -332,7 +312,7 @@ class ManufacturerTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class DeviceTypeTestCase(StandardTestCases.Views):
+class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = DeviceType
     model = DeviceType
 
 
     @classmethod
     @classmethod
@@ -524,14 +504,318 @@ device-bays:
         self.assertEqual(data[0]['model'], 'Device Type 1')
         self.assertEqual(data[0]['model'], 'Device Type 1')
 
 
 
 
-class DeviceRoleTestCase(StandardTestCases.Views):
-    model = DeviceRole
+#
+# DeviceType components
+#
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
+class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = ConsolePortTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetypes = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        ConsolePortTemplate.objects.bulk_create((
+            ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'),
+            ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'),
+            ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetypes[1].pk,
+            'name': 'Console Port Template X',
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetypes[1].pk,
+            'name_pattern': 'Console Port Template [4-6]',
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+        }
+
+        cls.bulk_edit_data = {
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+        }
+
+
+class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = ConsoleServerPortTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetypes = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        ConsoleServerPortTemplate.objects.bulk_create((
+            ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'),
+            ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'),
+            ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetypes[1].pk,
+            'name': 'Console Server Port Template X',
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetypes[1].pk,
+            'name_pattern': 'Console Server Port Template [4-6]',
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+        }
+
+        cls.bulk_edit_data = {
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+        }
+
+
+class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = PowerPortTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetypes = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        PowerPortTemplate.objects.bulk_create((
+            PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'),
+            PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'),
+            PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetypes[1].pk,
+            'name': 'Power Port Template X',
+            'type': PowerPortTypeChoices.TYPE_IEC_C14,
+            'maximum_draw': 100,
+            'allocated_draw': 50,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetypes[1].pk,
+            'name_pattern': 'Power Port Template [4-6]',
+            'type': PowerPortTypeChoices.TYPE_IEC_C14,
+            'maximum_draw': 100,
+            'allocated_draw': 50,
+        }
+
+        cls.bulk_edit_data = {
+            'type': PowerPortTypeChoices.TYPE_IEC_C14,
+            'maximum_draw': 100,
+            'allocated_draw': 50,
+        }
+
+
+class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = PowerOutletTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+
+        PowerOutletTemplate.objects.bulk_create((
+            PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
+            PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
+            PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 3'),
+        ))
+
+        powerports = (
+            PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
+        )
+        PowerPortTemplate.objects.bulk_create(powerports)
+
+        cls.form_data = {
+            'device_type': devicetype.pk,
+            'name': 'Power Outlet Template X',
+            'type': PowerOutletTypeChoices.TYPE_IEC_C13,
+            'power_port': powerports[0].pk,
+            'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetype.pk,
+            'name_pattern': 'Power Outlet Template [4-6]',
+            'type': PowerOutletTypeChoices.TYPE_IEC_C13,
+            'power_port': powerports[0].pk,
+            'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
+        }
+
+        cls.bulk_edit_data = {
+            'type': PowerOutletTypeChoices.TYPE_IEC_C13,
+            'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
+        }
+
+
+class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = InterfaceTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetypes = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        InterfaceTemplate.objects.bulk_create((
+            InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'),
+            InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'),
+            InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetypes[1].pk,
+            'name': 'Interface Template X',
+            'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            'mgmt_only': True,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetypes[1].pk,
+            'name_pattern': 'Interface Template [4-6]',
+            'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            'mgmt_only': True,
+        }
+
+        cls.bulk_edit_data = {
+            'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            'mgmt_only': True,
+        }
+
+
+class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = FrontPortTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+
+        rearports = (
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 4'),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 5'),
+            RearPortTemplate(device_type=devicetype, name='Rear Port Template 6'),
+        )
+        RearPortTemplate.objects.bulk_create(rearports)
+
+        FrontPortTemplate.objects.bulk_create((
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 1', rear_port=rearports[0], rear_port_position=1),
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 2', rear_port=rearports[1], rear_port_position=1),
+            FrontPortTemplate(device_type=devicetype, name='Front Port Template 3', rear_port=rearports[2], rear_port_position=1),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetype.pk,
+            'name': 'Front Port X',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'rear_port': rearports[3].pk,
+            'rear_port_position': 1,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetype.pk,
+            'name_pattern': 'Front Port [4-6]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'rear_port_set': [
+                '{}:1'.format(rp.pk) for rp in rearports[3:6]
+            ],
+        }
+
+        cls.bulk_edit_data = {
+            'type': PortTypeChoices.TYPE_8P8C,
+        }
+
+
+class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = RearPortTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetypes = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        RearPortTemplate.objects.bulk_create((
+            RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'),
+            RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'),
+            RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetypes[1].pk,
+            'name': 'Rear Port Template X',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetypes[1].pk,
+            'name_pattern': 'Rear Port Template [4-6]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 2,
+        }
+
+        cls.bulk_edit_data = {
+            'type': PortTypeChoices.TYPE_8P8C,
+        }
+
+
+class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = DeviceBayTemplate
+
+    # Disable inapplicable views
     test_bulk_edit_objects = None
     test_bulk_edit_objects = None
 
 
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetypes = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        DeviceBayTemplate.objects.bulk_create((
+            DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'),
+            DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'),
+            DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetypes[1].pk,
+            'name': 'Device Bay Template X',
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetypes[1].pk,
+            'name_pattern': 'Device Bay Template [4-6]',
+        }
+
+
+class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = DeviceRole
+
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -557,14 +841,9 @@ class DeviceRoleTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class PlatformTestCase(StandardTestCases.Views):
+class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Platform
     model = Platform
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -592,7 +871,7 @@ class PlatformTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class DeviceTestCase(StandardTestCases.Views):
+class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Device
     model = Device
 
 
     @classmethod
     @classmethod
@@ -677,17 +956,9 @@ class DeviceTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class ConsolePortTestCase(StandardTestCases.Views):
+class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsolePort
     model = ConsolePort
 
 
-    # Disable inapplicable views
-    test_get_object = None
-    test_bulk_edit_objects = None
-
-    # TODO
-    test_create_object = None
-    test_bulk_delete_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
@@ -704,11 +975,19 @@ class ConsolePortTestCase(StandardTestCases.Views):
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console port',
             'description': 'A console port',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connected_endpoint': None,
-            'connection_status': None,
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Console Port [4-6]',
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+            'description': 'A console port',
+            'tags': 'Alpha,Bravo,Charlie',
+        }
+
+        cls.bulk_edit_data = {
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+            'description': 'New description',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -719,17 +998,9 @@ class ConsolePortTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class ConsoleServerPortTestCase(StandardTestCases.Views):
+class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsoleServerPort
     model = ConsoleServerPort
 
 
-    # Disable inapplicable views
-    test_get_object = None
-
-    # TODO
-    test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
@@ -746,10 +1017,20 @@ class ConsoleServerPortTestCase(StandardTestCases.Views):
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console server port',
             'description': 'A console server port',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connection_status': None,
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Console Server Port [4-6]',
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+            'description': 'A console server port',
+            'tags': 'Alpha,Bravo,Charlie',
+        }
+
+        cls.bulk_edit_data = {
+            'device': device.pk,
+            'type': ConsolePortTypeChoices.TYPE_RJ45,
+            'description': 'New description',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -760,17 +1041,9 @@ class ConsoleServerPortTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class PowerPortTestCase(StandardTestCases.Views):
+class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerPort
     model = PowerPort
 
 
-    # Disable inapplicable views
-    test_get_object = None
-    test_bulk_edit_objects = None
-
-    # TODO
-    test_create_object = None
-    test_bulk_delete_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
@@ -789,10 +1062,23 @@ class PowerPortTestCase(StandardTestCases.Views):
             'allocated_draw': 50,
             'allocated_draw': 50,
             'description': 'A power port',
             'description': 'A power port',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connection_status': None,
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Power Port [4-6]]',
+            'type': PowerPortTypeChoices.TYPE_IEC_C14,
+            'maximum_draw': 100,
+            'allocated_draw': 50,
+            'description': 'A power port',
+            '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 = (
@@ -803,17 +1089,9 @@ class PowerPortTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class PowerOutletTestCase(StandardTestCases.Views):
+class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerOutlet
     model = PowerOutlet
 
 
-    # Disable inapplicable views
-    test_get_object = None
-
-    # TODO
-    test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
@@ -838,10 +1116,24 @@ class PowerOutletTestCase(StandardTestCases.Views):
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'description': 'A power outlet',
             'description': 'A power outlet',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connection_status': None,
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Power Outlet [4-6]',
+            'type': PowerOutletTypeChoices.TYPE_IEC_C13,
+            'power_port': powerports[1].pk,
+            'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
+            'description': 'A power outlet',
+            'tags': 'Alpha,Bravo,Charlie',
+        }
+
+        cls.bulk_edit_data = {
+            'device': device.pk,
+            'type': PowerOutletTypeChoices.TYPE_IEC_C13,
+            'power_port': powerports[1].pk,
+            'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
+            'description': 'New description',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -852,23 +1144,23 @@ class PowerOutletTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class InterfaceTestCase(StandardTestCases.Views):
+class InterfaceTestCase(
+    ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.DeviceComponentViewTestCase,
+):
     model = Interface
     model = Interface
 
 
-    # TODO
-    test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
 
 
-        Interface.objects.bulk_create([
+        interfaces = (
             Interface(device=device, name='Interface 1'),
             Interface(device=device, name='Interface 1'),
             Interface(device=device, name='Interface 2'),
             Interface(device=device, name='Interface 2'),
             Interface(device=device, name='Interface 3'),
             Interface(device=device, name='Interface 3'),
-        ])
+            Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG),
+        )
+        Interface.objects.bulk_create(interfaces)
 
 
         vlans = (
         vlans = (
             VLAN(vid=1, name='VLAN1', site=device.site),
             VLAN(vid=1, name='VLAN1', site=device.site),
@@ -884,19 +1176,45 @@ class InterfaceTestCase(StandardTestCases.Views):
             'name': 'Interface X',
             'name': 'Interface X',
             'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
             'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
             'enabled': False,
             'enabled': False,
-            'lag': None,
+            'lag': interfaces[3].pk,
             'mac_address': EUI('01:02:03:04:05:06'),
             'mac_address': EUI('01:02:03:04:05:06'),
             'mtu': 2000,
             'mtu': 2000,
             'mgmt_only': True,
             'mgmt_only': True,
-            'description': 'New description',
+            'description': 'A front port',
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': vlans[0].pk,
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
             'tagged_vlans': [v.pk for v in vlans[1:4]],
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connection_status': None,
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Interface [4-6]',
+            'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            'enabled': False,
+            'lag': interfaces[3].pk,
+            'mac_address': EUI('01:02:03:04:05:06'),
+            'mtu': 2000,
+            'mgmt_only': True,
+            'description': 'A front port',
+            'mode': InterfaceModeChoices.MODE_TAGGED,
+            'untagged_vlan': vlans[0].pk,
+            'tagged_vlans': [v.pk for v in vlans[1:4]],
+            'tags': 'Alpha,Bravo,Charlie',
+        }
+
+        cls.bulk_edit_data = {
+            'device': device.pk,
+            'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
+            'enabled': False,
+            'lag': interfaces[3].pk,
+            'mac_address': EUI('01:02:03:04:05:06'),
+            'mtu': 2000,
+            'mgmt_only': True,
+            'description': 'New description',
+            'mode': InterfaceModeChoices.MODE_TAGGED,
+            'untagged_vlan': vlans[0].pk,
+            'tagged_vlans': [v.pk for v in vlans[1:4]],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -907,17 +1225,9 @@ class InterfaceTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class FrontPortTestCase(StandardTestCases.Views):
+class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
     model = FrontPort
 
 
-    # Disable inapplicable views
-    test_get_object = None
-
-    # TODO
-    test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
@@ -946,9 +1256,22 @@ class FrontPortTestCase(StandardTestCases.Views):
             'rear_port_position': 1,
             'rear_port_position': 1,
             'description': 'New description',
             'description': 'New description',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Front Port [4-6]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'rear_port_set': [
+                '{}:1'.format(rp.pk) for rp in rearports[3:6]
+            ],
+            'description': 'New description',
+            'tags': 'Alpha,Bravo,Charlie',
+        }
+
+        cls.bulk_edit_data = {
+            'type': PortTypeChoices.TYPE_8P8C,
+            'description': 'New description',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -959,17 +1282,9 @@ class FrontPortTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class RearPortTestCase(StandardTestCases.Views):
+class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = RearPort
     model = RearPort
 
 
-    # Disable inapplicable views
-    test_get_object = None
-
-    # TODO
-    test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
@@ -985,11 +1300,22 @@ class RearPortTestCase(StandardTestCases.Views):
             'name': 'Rear Port X',
             'name': 'Rear Port X',
             'type': PortTypeChoices.TYPE_8P8C,
             'type': PortTypeChoices.TYPE_8P8C,
             'positions': 3,
             'positions': 3,
-            'description': 'New description',
+            'description': 'A rear port',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Rear Port [4-6]',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 3,
+            'description': 'A rear port',
+            'tags': 'Alpha,Bravo,Charlie',
+        }
+
+        cls.bulk_edit_data = {
+            'type': PortTypeChoices.TYPE_8P8C,
+            'description': 'New description',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -1000,16 +1326,11 @@ class RearPortTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class DeviceBayTestCase(StandardTestCases.Views):
+class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = DeviceBay
     model = DeviceBay
 
 
     # Disable inapplicable views
     # Disable inapplicable views
-    test_get_object = None
-
-    # TODO
-    test_create_object = None
     test_bulk_edit_objects = None
     test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1030,9 +1351,13 @@ class DeviceBayTestCase(StandardTestCases.Views):
             'name': 'Device Bay X',
             'name': 'Device Bay X',
             'description': 'A device bay',
             'description': 'A device bay',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'installed_device': None,
+        cls.bulk_create_data = {
+            'device': device2.pk,
+            'name_pattern': 'Device Bay [4-6]',
+            'description': 'A device bay',
+            'tags': 'Alpha,Bravo,Charlie',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -1043,15 +1368,9 @@ class DeviceBayTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class InventoryItemTestCase(StandardTestCases.Views):
+class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = InventoryItem
     model = InventoryItem
 
 
-    # Disable inapplicable views
-    test_get_object = None
-
-    # TODO
-    test_create_object = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
@@ -1076,12 +1395,17 @@ class InventoryItemTestCase(StandardTestCases.Views):
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
         }
         }
 
 
-        cls.csv_data = (
-            "device,name",
-            "Device 1,Inventory Item 4",
-            "Device 1,Inventory Item 5",
-            "Device 1,Inventory Item 6",
-        )
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Inventory Item [4-6]',
+            'manufacturer': manufacturer.pk,
+            'parent': None,
+            'discovered': False,
+            'part_id': '123456',
+            'serial': '123ABC',
+            'description': 'An inventory item',
+            'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'device': device.pk,
             'device': device.pk,
@@ -1090,8 +1414,15 @@ class InventoryItemTestCase(StandardTestCases.Views):
             'description': 'New description',
             'description': 'New description',
         }
         }
 
 
+        cls.csv_data = (
+            "device,name",
+            "Device 1,Inventory Item 4",
+            "Device 1,Inventory Item 5",
+            "Device 1,Inventory Item 6",
+        )
+
 
 
-class CableTestCase(StandardTestCases.Views):
+class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Cable
     model = Cable
 
 
     # TODO: Creation URL needs termination context
     # TODO: Creation URL needs termination context
@@ -1165,7 +1496,7 @@ class CableTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class VirtualChassisTestCase(StandardTestCases.Views):
+class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VirtualChassis
     model = VirtualChassis
 
 
     # Disable inapplicable tests
     # Disable inapplicable tests
@@ -1219,7 +1550,7 @@ class VirtualChassisTestCase(StandardTestCases.Views):
         Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
         Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
 
 
 
 
-class PowerPanelTestCase(StandardTestCases.Views):
+class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = PowerPanel
     model = PowerPanel
 
 
     # Disable inapplicable tests
     # Disable inapplicable tests
@@ -1260,7 +1591,7 @@ class PowerPanelTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class PowerFeedTestCase(StandardTestCases.Views):
+class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = PowerFeed
     model = PowerFeed
 
 
     @classmethod
     @classmethod

+ 269 - 248
netbox/dcim/urls.py

@@ -14,317 +14,338 @@ app_name = 'dcim'
 urlpatterns = [
 urlpatterns = [
 
 
     # Regions
     # Regions
-    path(r'regions/', views.RegionListView.as_view(), name='region_list'),
-    path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'),
-    path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
-    path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
-    path(r'regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
-    path(r'regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
+    path('regions/', views.RegionListView.as_view(), name='region_list'),
+    path('regions/add/', views.RegionCreateView.as_view(), name='region_add'),
+    path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
+    path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
+    path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
+    path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
 
 
     # Sites
     # Sites
-    path(r'sites/', views.SiteListView.as_view(), name='site_list'),
-    path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'),
-    path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
-    path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
-    path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
-    path(r'sites/<slug:slug>/', views.SiteView.as_view(), name='site'),
-    path(r'sites/<slug:slug>/edit/', views.SiteEditView.as_view(), name='site_edit'),
-    path(r'sites/<slug:slug>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
-    path(r'sites/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
-    path(r'sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
+    path('sites/', views.SiteListView.as_view(), name='site_list'),
+    path('sites/add/', views.SiteCreateView.as_view(), name='site_add'),
+    path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
+    path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
+    path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
+    path('sites/<slug:slug>/', views.SiteView.as_view(), name='site'),
+    path('sites/<slug:slug>/edit/', views.SiteEditView.as_view(), name='site_edit'),
+    path('sites/<slug:slug>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
+    path('sites/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
+    path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
 
 
     # Rack groups
     # Rack groups
-    path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
-    path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
-    path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
-    path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
-    path(r'rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
-    path(r'rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
+    path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
+    path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
+    path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
+    path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
+    path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
+    path('rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
 
 
     # Rack roles
     # Rack roles
-    path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
-    path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
-    path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
-    path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
-    path(r'rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
-    path(r'rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
+    path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
+    path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
+    path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
+    path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
+    path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
+    path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
 
 
     # Rack reservations
     # Rack reservations
-    path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
-    path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
-    path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
-    path(r'rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
-    path(r'rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
-    path(r'rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
+    path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
+    path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
+    path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
+    path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
+    path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
+    path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
 
 
     # Racks
     # Racks
-    path(r'racks/', views.RackListView.as_view(), name='rack_list'),
-    path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
-    path(r'racks/add/', views.RackCreateView.as_view(), name='rack_add'),
-    path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
-    path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
-    path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
-    path(r'racks/<int:pk>/', views.RackView.as_view(), name='rack'),
-    path(r'racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
-    path(r'racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
-    path(r'racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
-    path(r'racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
-    path(r'racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
+    path('racks/', views.RackListView.as_view(), name='rack_list'),
+    path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
+    path('racks/add/', views.RackCreateView.as_view(), name='rack_add'),
+    path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
+    path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
+    path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
+    path('racks/<int:pk>/', views.RackView.as_view(), name='rack'),
+    path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
+    path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
+    path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
+    path('racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
+    path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
 
     # Manufacturers
     # Manufacturers
-    path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
-    path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
-    path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
-    path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
-    path(r'manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
-    path(r'manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
+    path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
+    path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
+    path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
+    path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
+    path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
+    path('manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
 
 
     # Device types
     # Device types
-    path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
-    path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
-    path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
-    path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
-    path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
-    path(r'device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
-    path(r'device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
-    path(r'device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
-    path(r'device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
+    path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
+    path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
+    path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
+    path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
+    path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
+    path('device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
+    path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
+    path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
+    path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
 
 
     # Console port templates
     # Console port templates
-    path(r'device-types/<int:pk>/console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
-    path(r'device-types/<int:pk>/console-ports/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
-    path(r'console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
+    path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
+    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/<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(r'device-types/<int:pk>/console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
-    path(r'device-types/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
-    path(r'console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
+    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/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>/delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
 
 
     # Power port templates
     # Power port templates
-    path(r'device-types/<int:pk>/power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
-    path(r'device-types/<int:pk>/power-ports/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
-    path(r'power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
+    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/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>/delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
 
 
     # Power outlet templates
     # Power outlet templates
-    path(r'device-types/<int:pk>/power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
-    path(r'device-types/<int:pk>/power-outlets/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
-    path(r'power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
+    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/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>/delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
 
 
     # Interface templates
     # Interface templates
-    path(r'device-types/<int:pk>/interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
-    path(r'device-types/<int:pk>/interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
-    path(r'device-types/<int:pk>/interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
-    path(r'interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
+    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/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>/delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
 
 
     # Front port templates
     # Front port templates
-    path(r'device-types/<int:pk>/front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'),
-    path(r'device-types/<int:pk>/front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'),
-    path(r'front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
+    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/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>/delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
 
 
     # Rear port templates
     # Rear port templates
-    path(r'device-types/<int:pk>/rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'),
-    path(r'device-types/<int:pk>/rear-ports/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'),
-    path(r'rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
+    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/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>/delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
 
 
     # Device bay templates
     # Device bay templates
-    path(r'device-types/<int:pk>/device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
-    path(r'device-types/<int:pk>/device-bays/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
-    path(r'device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
+    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/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>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
 
 
     # Device roles
     # Device roles
-    path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
-    path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
-    path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
-    path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
-    path(r'device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
-    path(r'device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
+    path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
+    path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
+    path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
+    path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
+    path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
+    path('device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
 
 
     # Platforms
     # Platforms
-    path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'),
-    path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
-    path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
-    path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
-    path(r'platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
-    path(r'platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
+    path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
+    path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
+    path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
+    path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
+    path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
+    path('platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
 
 
     # Devices
     # Devices
-    path(r'devices/', views.DeviceListView.as_view(), name='device_list'),
-    path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
-    path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
-    path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
-    path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
-    path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
-    path(r'devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
-    path(r'devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
-    path(r'devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
-    path(r'devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
-    path(r'devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
-    path(r'devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
-    path(r'devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
-    path(r'devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
-    path(r'devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
-    path(r'devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
-    path(r'devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
-    path(r'devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
+    path('devices/', views.DeviceListView.as_view(), name='device_list'),
+    path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
+    path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
+    path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
+    path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
+    path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
+    path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
+    path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
+    path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
+    path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
+    path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
+    path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
+    path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
+    path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
+    path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
+    path('devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
+    path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
+    path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
 
 
     # Console ports
     # Console ports
-    path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
-    path(r'devices/<int:pk>/console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
-    path(r'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
-    path(r'console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
-    path(r'console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
-    path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
-    path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
-    path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
-    path(r'console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
+    path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
+    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/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/<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>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
+    path('console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
+    path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
 
 
     # Console server ports
     # Console server ports
-    path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
-    path(r'devices/<int:pk>/console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
-    path(r'devices/<int:pk>/console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
-    path(r'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
-    path(r'console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'),
-    path(r'console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
-    path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
-    path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
-    path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
-    path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
-    path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
-    path(r'console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
+    path('console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'),
+    path('console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
+    path('console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
+    path('console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
+    path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
+    path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
+    path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
+    path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
+    path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
+    path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
+    path('console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
+    path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
 
 
     # Power ports
     # Power ports
-    path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
-    path(r'devices/<int:pk>/power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
-    path(r'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
-    path(r'power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
-    path(r'power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
-    path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
-    path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
-    path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
-    path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
+    path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
+    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/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/<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>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
+    path('power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
+    path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
 
 
     # Power outlets
     # Power outlets
-    path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
-    path(r'devices/<int:pk>/power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
-    path(r'devices/<int:pk>/power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
-    path(r'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
-    path(r'power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'),
-    path(r'power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
-    path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
-    path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
-    path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
-    path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
-    path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
-    path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
+    path('power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'),
+    path('power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
+    path('power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
+    path('power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
+    path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
+    path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
+    path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
+    path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
+    path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
+    path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
+    path('power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
+    path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
 
 
     # Interfaces
     # Interfaces
-    path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
-    path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
-    path(r'devices/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
-    path(r'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    path(r'interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
-    path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
-    path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
-    path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
-    path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
-    path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
-    path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
-    path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
-    path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
-    path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
+    path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
+    path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
+    path('interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
+    path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
+    path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
+    path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
+    path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
+    path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
+    path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
+    path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
+    path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
+    path('interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
+    path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
 
 
     # Front ports
     # Front ports
-    # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
-    path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
-    path(r'devices/<int:pk>/front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
-    path(r'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
-    path(r'front-ports/', views.FrontPortListView.as_view(), name='frontport_list'),
-    path(r'front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
-    path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
-    path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
-    path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
-    path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
-    path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
-    path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
+    path('front-ports/', views.FrontPortListView.as_view(), name='frontport_list'),
+    path('front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
+    path('front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
+    path('front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
+    path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
+    path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
+    path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
+    path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
+    path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
+    path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
+    path('front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
+    # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
 
 
     # Rear ports
     # Rear ports
-    # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
-    path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
-    path(r'devices/<int:pk>/rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
-    path(r'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
-    path(r'rear-ports/', views.RearPortListView.as_view(), name='rearport_list'),
-    path(r'rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
-    path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
-    path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
-    path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
-    path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
-    path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
-    path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
+    path('rear-ports/', views.RearPortListView.as_view(), name='rearport_list'),
+    path('rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
+    path('rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
+    path('rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
+    path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
+    path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
+    path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
+    path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
+    path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
+    path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
+    path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
+    # path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
 
 
     # Device bays
     # Device bays
-    path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
-    path(r'devices/<int:pk>/bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
-    path(r'devices/<int:pk>/bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
-    path(r'device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
-    path(r'device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
-    path(r'device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
-    path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
-    path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
-    path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
-    path(r'device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
+    path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
+    path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
+    path('device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
+    # TODO: Bulk edit view for DeviceBays
+    path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
+    path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
+    path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
+    path('device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
+    path('device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
+    path('device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
+    path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
 
 
     # Inventory items
     # Inventory items
-    path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
-    path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
-    path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
-    path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
-    path(r'inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
-    path(r'inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
-    path(r'devices/<int:device>/inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
+    path('inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
+    path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'),
+    path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
+    path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
+    # TODO: Bulk rename view for InventoryItems
+    path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
+    path('inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
+    path('inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
 
 
     # Cables
     # Cables
-    path(r'cables/', views.CableListView.as_view(), name='cable_list'),
-    path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
-    path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
-    path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
-    path(r'cables/<int:pk>/', views.CableView.as_view(), name='cable'),
-    path(r'cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
-    path(r'cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
-    path(r'cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
+    path('cables/', views.CableListView.as_view(), name='cable_list'),
+    path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
+    path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
+    path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
+    path('cables/<int:pk>/', views.CableView.as_view(), name='cable'),
+    path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
+    path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
+    path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
 
 
     # Console/power/interface connections (read-only)
     # Console/power/interface connections (read-only)
-    path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
-    path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
-    path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
+    path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
+    path('power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
+    path('interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
 
 
     # Virtual chassis
     # Virtual chassis
-    path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
-    path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
-    path(r'virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
-    path(r'virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
-    path(r'virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
-    path(r'virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
-    path(r'virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
+    path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
+    path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
+    path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
+    path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
+    path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
+    path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
+    path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
 
 
     # Power panels
     # Power panels
-    path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
-    path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
-    path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
-    path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
-    path(r'power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
-    path(r'power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
-    path(r'power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
-    path(r'power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
+    path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
+    path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
+    path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
+    path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
+    path('power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
+    path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
+    path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
+    path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
 
 
     # Power feeds
     # Power feeds
-    path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
-    path(r'power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
-    path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
-    path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
-    path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
-    path(r'power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
-    path(r'power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
-    path(r'power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
-    path(r'power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
+    path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
+    path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
+    path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
+    path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
+    path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
+    path('power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
+    path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
+    path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
+    path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
 
 
 ]
 ]

+ 154 - 69
netbox/dcim/views.py

@@ -700,13 +700,11 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 
 
 #
 #
-# Device type components
+# Console port templates
 #
 #
 
 
 class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleporttemplate'
     permission_required = 'dcim.add_consoleporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = ConsolePortTemplate
     model = ConsolePortTemplate
     form = forms.ConsolePortTemplateCreateForm
     form = forms.ConsolePortTemplateCreateForm
     model_form = forms.ConsolePortTemplateForm
     model_form = forms.ConsolePortTemplateForm
@@ -719,17 +717,30 @@ 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):
+    permission_required = 'dcim.change_consoleporttemplate'
+    queryset = ConsolePortTemplate.objects.all()
+    table = tables.ConsolePortTemplateTable
+    form = forms.ConsolePortTemplateBulkEditForm
+
+
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleporttemplate'
     permission_required = 'dcim.delete_consoleporttemplate'
     queryset = ConsolePortTemplate.objects.all()
     queryset = ConsolePortTemplate.objects.all()
-    parent_model = DeviceType
     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'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
     form = forms.ConsoleServerPortTemplateCreateForm
     form = forms.ConsoleServerPortTemplateCreateForm
     model_form = forms.ConsoleServerPortTemplateForm
     model_form = forms.ConsoleServerPortTemplateForm
@@ -742,17 +753,30 @@ 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):
+    permission_required = 'dcim.change_consoleserverporttemplate'
+    queryset = ConsoleServerPortTemplate.objects.all()
+    table = tables.ConsoleServerPortTemplateTable
+    form = forms.ConsoleServerPortTemplateBulkEditForm
+
+
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverporttemplate'
     permission_required = 'dcim.delete_consoleserverporttemplate'
     queryset = ConsoleServerPortTemplate.objects.all()
     queryset = ConsoleServerPortTemplate.objects.all()
-    parent_model = DeviceType
     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'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = PowerPortTemplate
     model = PowerPortTemplate
     form = forms.PowerPortTemplateCreateForm
     form = forms.PowerPortTemplateCreateForm
     model_form = forms.PowerPortTemplateForm
     model_form = forms.PowerPortTemplateForm
@@ -765,17 +789,30 @@ 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):
+    permission_required = 'dcim.change_powerporttemplate'
+    queryset = PowerPortTemplate.objects.all()
+    table = tables.PowerPortTemplateTable
+    form = forms.PowerPortTemplateBulkEditForm
+
+
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerporttemplate'
     permission_required = 'dcim.delete_powerporttemplate'
     queryset = PowerPortTemplate.objects.all()
     queryset = PowerPortTemplate.objects.all()
-    parent_model = DeviceType
     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'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = PowerOutletTemplate
     model = PowerOutletTemplate
     form = forms.PowerOutletTemplateCreateForm
     form = forms.PowerOutletTemplateCreateForm
     model_form = forms.PowerOutletTemplateForm
     model_form = forms.PowerOutletTemplateForm
@@ -788,17 +825,30 @@ 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):
+    permission_required = 'dcim.change_poweroutlettemplate'
+    queryset = PowerOutletTemplate.objects.all()
+    table = tables.PowerOutletTemplateTable
+    form = forms.PowerOutletTemplateBulkEditForm
+
+
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlettemplate'
     permission_required = 'dcim.delete_poweroutlettemplate'
     queryset = PowerOutletTemplate.objects.all()
     queryset = PowerOutletTemplate.objects.all()
-    parent_model = DeviceType
     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'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = InterfaceTemplate
     model = InterfaceTemplate
     form = forms.InterfaceTemplateCreateForm
     form = forms.InterfaceTemplateCreateForm
     model_form = forms.InterfaceTemplateForm
     model_form = forms.InterfaceTemplateForm
@@ -811,10 +861,14 @@ 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()
-    parent_model = DeviceType
     table = tables.InterfaceTemplateTable
     table = tables.InterfaceTemplateTable
     form = forms.InterfaceTemplateBulkEditForm
     form = forms.InterfaceTemplateBulkEditForm
 
 
@@ -822,14 +876,15 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interfacetemplate'
     permission_required = 'dcim.delete_interfacetemplate'
     queryset = InterfaceTemplate.objects.all()
     queryset = InterfaceTemplate.objects.all()
-    parent_model = DeviceType
     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'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = FrontPortTemplate
     model = FrontPortTemplate
     form = forms.FrontPortTemplateCreateForm
     form = forms.FrontPortTemplateCreateForm
     model_form = forms.FrontPortTemplateForm
     model_form = forms.FrontPortTemplateForm
@@ -842,17 +897,30 @@ 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):
+    permission_required = 'dcim.change_frontporttemplate'
+    queryset = FrontPortTemplate.objects.all()
+    table = tables.FrontPortTemplateTable
+    form = forms.FrontPortTemplateBulkEditForm
+
+
 class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_frontporttemplate'
     permission_required = 'dcim.delete_frontporttemplate'
     queryset = FrontPortTemplate.objects.all()
     queryset = FrontPortTemplate.objects.all()
-    parent_model = DeviceType
     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'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = RearPortTemplate
     model = RearPortTemplate
     form = forms.RearPortTemplateCreateForm
     form = forms.RearPortTemplateCreateForm
     model_form = forms.RearPortTemplateForm
     model_form = forms.RearPortTemplateForm
@@ -865,17 +933,30 @@ 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):
+    permission_required = 'dcim.change_rearporttemplate'
+    queryset = RearPortTemplate.objects.all()
+    table = tables.RearPortTemplateTable
+    form = forms.RearPortTemplateBulkEditForm
+
+
 class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rearporttemplate'
     permission_required = 'dcim.delete_rearporttemplate'
     queryset = RearPortTemplate.objects.all()
     queryset = RearPortTemplate.objects.all()
-    parent_model = DeviceType
     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'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = DeviceBayTemplate
     model = DeviceBayTemplate
     form = forms.DeviceBayTemplateCreateForm
     form = forms.DeviceBayTemplateCreateForm
     model_form = forms.DeviceBayTemplateForm
     model_form = forms.DeviceBayTemplateForm
@@ -888,10 +969,21 @@ 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):
+#     permission_required = 'dcim.change_devicebaytemplate'
+#     queryset = DeviceBayTemplate.objects.all()
+#     table = tables.DeviceBayTemplateTable
+#     form = forms.DeviceBayTemplateBulkEditForm
+
+
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebaytemplate'
     permission_required = 'dcim.delete_devicebaytemplate'
     queryset = DeviceBayTemplate.objects.all()
     queryset = DeviceBayTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.DeviceBayTemplateTable
     table = tables.DeviceBayTemplateTable
 
 
 
 
@@ -1200,13 +1292,11 @@ 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):
     permission_required = 'dcim.add_consoleport'
     permission_required = 'dcim.add_consoleport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsolePort
     model = ConsolePort
     form = forms.ConsolePortCreateForm
     form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
     model_form = forms.ConsolePortForm
@@ -1231,11 +1321,18 @@ 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()
-    parent_model = Device
     table = tables.ConsolePortTable
     table = tables.ConsolePortTable
+    default_return_url = 'dcim:consoleport_list'
 
 
 
 
 #
 #
@@ -1248,13 +1345,11 @@ 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):
     permission_required = 'dcim.add_consoleserverport'
     permission_required = 'dcim.add_consoleserverport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsoleServerPort
     model = ConsoleServerPort
     form = forms.ConsoleServerPortCreateForm
     form = forms.ConsoleServerPortCreateForm
     model_form = forms.ConsoleServerPortForm
     model_form = forms.ConsoleServerPortForm
@@ -1282,7 +1377,6 @@ class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_consoleserverport'
     permission_required = 'dcim.change_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    parent_model = Device
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
     form = forms.ConsoleServerPortBulkEditForm
     form = forms.ConsoleServerPortBulkEditForm
 
 
@@ -1302,8 +1396,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     permission_required = 'dcim.delete_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    parent_model = Device
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
+    default_return_url = 'dcim:consoleserverport_list'
 
 
 
 
 #
 #
@@ -1316,13 +1410,11 @@ 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):
     permission_required = 'dcim.add_powerport'
     permission_required = 'dcim.add_powerport'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerPort
     model = PowerPort
     form = forms.PowerPortCreateForm
     form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
     model_form = forms.PowerPortForm
@@ -1347,11 +1439,18 @@ 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()
-    parent_model = Device
     table = tables.PowerPortTable
     table = tables.PowerPortTable
+    default_return_url = 'dcim:powerport_list'
 
 
 
 
 #
 #
@@ -1364,13 +1463,11 @@ 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):
     permission_required = 'dcim.add_poweroutlet'
     permission_required = 'dcim.add_poweroutlet'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerOutlet
     model = PowerOutlet
     form = forms.PowerOutletCreateForm
     form = forms.PowerOutletCreateForm
     model_form = forms.PowerOutletForm
     model_form = forms.PowerOutletForm
@@ -1398,7 +1495,6 @@ class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_poweroutlet'
     permission_required = 'dcim.change_poweroutlet'
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    parent_model = Device
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
     form = forms.PowerOutletBulkEditForm
     form = forms.PowerOutletBulkEditForm
 
 
@@ -1418,8 +1514,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     permission_required = 'dcim.delete_poweroutlet'
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    parent_model = Device
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
+    default_return_url = 'dcim:poweroutlet_list'
 
 
 
 
 #
 #
@@ -1432,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):
@@ -1473,8 +1569,6 @@ class InterfaceView(PermissionRequiredMixin, View):
 
 
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interface'
     permission_required = 'dcim.add_interface'
-    parent_model = Device
-    parent_field = 'device'
     model = Interface
     model = Interface
     form = forms.InterfaceCreateForm
     form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
@@ -1503,7 +1597,6 @@ class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    parent_model = Device
     table = tables.InterfaceTable
     table = tables.InterfaceTable
     form = forms.InterfaceBulkEditForm
     form = forms.InterfaceBulkEditForm
 
 
@@ -1523,8 +1616,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    parent_model = Device
     table = tables.InterfaceTable
     table = tables.InterfaceTable
+    default_return_url = 'dcim:interface_list'
 
 
 
 
 #
 #
@@ -1537,13 +1630,11 @@ 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):
     permission_required = 'dcim.add_frontport'
     permission_required = 'dcim.add_frontport'
-    parent_model = Device
-    parent_field = 'device'
     model = FrontPort
     model = FrontPort
     form = forms.FrontPortCreateForm
     form = forms.FrontPortCreateForm
     model_form = forms.FrontPortForm
     model_form = forms.FrontPortForm
@@ -1571,7 +1662,6 @@ class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_frontport'
     permission_required = 'dcim.change_frontport'
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    parent_model = Device
     table = tables.FrontPortTable
     table = tables.FrontPortTable
     form = forms.FrontPortBulkEditForm
     form = forms.FrontPortBulkEditForm
 
 
@@ -1591,8 +1681,8 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_frontport'
     permission_required = 'dcim.delete_frontport'
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    parent_model = Device
     table = tables.FrontPortTable
     table = tables.FrontPortTable
+    default_return_url = 'dcim:frontport_list'
 
 
 
 
 #
 #
@@ -1605,13 +1695,11 @@ 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):
     permission_required = 'dcim.add_rearport'
     permission_required = 'dcim.add_rearport'
-    parent_model = Device
-    parent_field = 'device'
     model = RearPort
     model = RearPort
     form = forms.RearPortCreateForm
     form = forms.RearPortCreateForm
     model_form = forms.RearPortForm
     model_form = forms.RearPortForm
@@ -1639,7 +1727,6 @@ class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rearport'
     permission_required = 'dcim.change_rearport'
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    parent_model = Device
     table = tables.RearPortTable
     table = tables.RearPortTable
     form = forms.RearPortBulkEditForm
     form = forms.RearPortBulkEditForm
 
 
@@ -1659,8 +1746,8 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rearport'
     permission_required = 'dcim.delete_rearport'
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    parent_model = Device
     table = tables.RearPortTable
     table = tables.RearPortTable
+    default_return_url = 'dcim:rearport_list'
 
 
 
 
 #
 #
@@ -1675,13 +1762,11 @@ 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):
     permission_required = 'dcim.add_devicebay'
     permission_required = 'dcim.add_devicebay'
-    parent_model = Device
-    parent_field = 'device'
     model = DeviceBay
     model = DeviceBay
     form = forms.DeviceBayCreateForm
     form = forms.DeviceBayCreateForm
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
@@ -1784,8 +1869,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
 class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebay'
     permission_required = 'dcim.delete_devicebay'
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
-    parent_model = Device
     table = tables.DeviceBayTable
     table = tables.DeviceBayTable
+    default_return_url = 'dcim:devicebay_list'
 
 
 
 
 #
 #
@@ -2156,13 +2241,13 @@ class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
     model = InventoryItem
     model = InventoryItem
     model_form = forms.InventoryItemForm
     model_form = forms.InventoryItemForm
 
 
-    def alter_obj(self, obj, request, url_args, url_kwargs):
-        if 'device' in url_kwargs:
-            obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
-        return obj
 
 
-    def get_return_url(self, request, obj):
-        return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk})
+class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_inventoryitem'
+    model = InventoryItem
+    form = forms.InventoryItemCreateForm
+    model_form = forms.InventoryItemForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):

+ 10 - 10
netbox/extras/api/urls.py

@@ -15,34 +15,34 @@ router = routers.DefaultRouter()
 router.APIRootView = ExtrasRootView
 router.APIRootView = ExtrasRootView
 
 
 # Field choices
 # Field choices
-router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
 
 
 # Custom field choices
 # Custom field choices
-router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
+router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
 
 
 # Graphs
 # Graphs
-router.register(r'graphs', views.GraphViewSet)
+router.register('graphs', views.GraphViewSet)
 
 
 # Export templates
 # Export templates
-router.register(r'export-templates', views.ExportTemplateViewSet)
+router.register('export-templates', views.ExportTemplateViewSet)
 
 
 # Tags
 # Tags
-router.register(r'tags', views.TagViewSet)
+router.register('tags', views.TagViewSet)
 
 
 # Image attachments
 # Image attachments
-router.register(r'image-attachments', views.ImageAttachmentViewSet)
+router.register('image-attachments', views.ImageAttachmentViewSet)
 
 
 # Config contexts
 # Config contexts
-router.register(r'config-contexts', views.ConfigContextViewSet)
+router.register('config-contexts', views.ConfigContextViewSet)
 
 
 # Reports
 # Reports
-router.register(r'reports', views.ReportViewSet, basename='report')
+router.register('reports', views.ReportViewSet, basename='report')
 
 
 # Scripts
 # Scripts
-router.register(r'scripts', views.ScriptViewSet, basename='script')
+router.register('scripts', views.ScriptViewSet, basename='script')
 
 
 # Change logging
 # Change logging
-router.register(r'object-changes', views.ObjectChangeViewSet)
+router.register('object-changes', views.ObjectChangeViewSet)
 
 
 app_name = 'extras-api'
 app_name = 'extras-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 85 - 43
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 *
@@ -133,7 +134,8 @@ class CustomFieldFilterForm(forms.Form):
             filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
             filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
         )
         )
         for cf in custom_fields:
         for cf in custom_fields:
-            self.fields[cf.name] = cf.to_form_field(set_initial=True, enforce_required=False)
+            field_name = 'cf_{}'.format(cf.name)
+            self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
 
 
 
 
 #
 #
@@ -189,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,
@@ -203,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):
@@ -264,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",
@@ -386,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'),

+ 111 - 0
netbox/extras/management/commands/renaturalize.py

@@ -0,0 +1,111 @@
+from django.apps import apps
+from django.core.management.base import BaseCommand, CommandError
+
+from utilities.fields import NaturalOrderingField
+
+
+class Command(BaseCommand):
+    help = "Recalculate natural ordering values for the specified models"
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            'args', metavar='app_label.ModelName', nargs='*',
+            help='One or more specific models (each prefixed with its app_label) to renaturalize',
+        )
+
+    def _get_models(self, names):
+        """
+        Compile a list of models to be renaturalized. If no names are specified, all models which have one or more
+        NaturalOrderingFields will be included.
+        """
+        models = []
+
+        if names:
+            # Collect all NaturalOrderingFields present on the specified models
+            for name in names:
+                try:
+                    app_label, model_name = name.split('.')
+                except ValueError:
+                    raise CommandError(
+                        "Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name)
+                    )
+                try:
+                    app_config = apps.get_app_config(app_label)
+                except LookupError as e:
+                    raise CommandError(str(e))
+                try:
+                    model = app_config.get_model(model_name)
+                except LookupError:
+                    raise CommandError("Unknown model: {}.{}".format(app_label, model_name))
+                fields = [
+                    field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
+                ]
+                if not fields:
+                    raise CommandError(
+                        "Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name)
+                    )
+                models.append(
+                    (model, fields)
+                )
+
+        else:
+            # Find *all* models with NaturalOrderingFields
+            for app_config in apps.get_app_configs():
+                for model in app_config.models.values():
+                    fields = [
+                        field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
+                    ]
+                    if fields:
+                        models.append(
+                            (model, fields)
+                        )
+
+        return models
+
+    def handle(self, *args, **options):
+
+        models = self._get_models(args)
+
+        if options['verbosity']:
+            self.stdout.write("Renaturalizing {} models.".format(len(models)))
+
+        for model, fields in models:
+            for field in fields:
+                target_field = field.target_field
+                naturalize = field.naturalize_function
+                count = 0
+
+                # Print the model and field name
+                if options['verbosity']:
+                    self.stdout.write(
+                        "{}.{} ({})... ".format(model._meta.label, field.target_field, field.name),
+                        ending='\n' if options['verbosity'] >= 2 else ''
+                    )
+                    self.stdout.flush()
+
+                # Find all unique values for the field
+                queryset = model.objects.values_list(target_field, flat=True).order_by(target_field).distinct()
+                for value in queryset:
+                    naturalized_value = naturalize(value)
+
+                    if options['verbosity'] >= 2:
+                        self.stdout.write("  {} -> {}".format(value, naturalized_value), ending='')
+                        self.stdout.flush()
+
+                    # Update each unique field value in bulk
+                    changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
+
+                    if options['verbosity'] >= 2:
+                        self.stdout.write(" ({})".format(changed))
+                    count += changed
+
+                # Print the total count of alterations for the field
+                if options['verbosity'] >= 2:
+                    self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format(
+                        count, model._meta.verbose_name_plural, queryset.count()
+                    )))
+                elif options['verbosity']:
+                    self.stdout.write(self.style.SUCCESS(str(count)))
+
+        if options['verbosity']:
+            self.stdout.write(self.style.SUCCESS("Done."))

+ 7 - 2
netbox/extras/scripts.py

@@ -48,7 +48,7 @@ class ScriptVariable:
     """
     """
     form_field = forms.CharField
     form_field = forms.CharField
 
 
-    def __init__(self, label='', description='', default=None, required=True):
+    def __init__(self, label='', description='', default=None, required=True, widget=None):
 
 
         # Initialize field attributes
         # Initialize field attributes
         if not hasattr(self, 'field_attrs'):
         if not hasattr(self, 'field_attrs'):
@@ -59,6 +59,8 @@ class ScriptVariable:
             self.field_attrs['help_text'] = description
             self.field_attrs['help_text'] = description
         if default:
         if default:
             self.field_attrs['initial'] = default
             self.field_attrs['initial'] = default
+        if widget:
+            self.field_attrs['widget'] = widget
         self.field_attrs['required'] = required
         self.field_attrs['required'] = required
 
 
         # Initialize the list of optional validators if none have already been defined
         # Initialize the list of optional validators if none have already been defined
@@ -71,7 +73,10 @@ class ScriptVariable:
         """
         """
         form_field = self.form_field(**self.field_attrs)
         form_field = self.form_field(**self.field_attrs)
         if not isinstance(form_field.widget, forms.CheckboxInput):
         if not isinstance(form_field.widget, forms.CheckboxInput):
-            form_field.widget.attrs['class'] = 'form-control'
+            if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys():
+                form_field.widget.attrs['class'] += ' form-control'
+            else:
+                form_field.widget.attrs['class'] = 'form-control'
 
 
         return form_field
         return form_field
 
 

+ 3 - 3
netbox/extras/tests/test_views.py

@@ -7,10 +7,10 @@ from django.urls import reverse
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import ObjectChangeActionChoices
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ConfigContext, ObjectChange, Tag
 from extras.models import ConfigContext, ObjectChange, Tag
-from utilities.testing import StandardTestCases, TestCase
+from utilities.testing import ViewTestCases, TestCase
 
 
 
 
-class TagTestCase(StandardTestCases.Views):
+class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Tag
     model = Tag
 
 
     # Disable inapplicable tests
     # Disable inapplicable tests
@@ -38,7 +38,7 @@ class TagTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class ConfigContextTestCase(StandardTestCases.Views):
+class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ConfigContext
     model = ConfigContext
 
 
     # Disable inapplicable tests
     # Disable inapplicable tests

+ 23 - 23
netbox/extras/urls.py

@@ -8,38 +8,38 @@ app_name = 'extras'
 urlpatterns = [
 urlpatterns = [
 
 
     # Tags
     # Tags
-    path(r'tags/', views.TagListView.as_view(), name='tag_list'),
-    path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
-    path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
-    path(r'tags/<str:slug>/', views.TagView.as_view(), name='tag'),
-    path(r'tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
-    path(r'tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
-    path(r'tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
+    path('tags/', views.TagListView.as_view(), name='tag_list'),
+    path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
+    path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
+    path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
+    path('tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
+    path('tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
+    path('tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
 
 
     # Config contexts
     # Config contexts
-    path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
-    path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
-    path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
-    path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
-    path(r'config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
-    path(r'config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
-    path(r'config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
+    path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
+    path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
+    path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
+    path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
+    path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
+    path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
+    path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
 
 
     # Image attachments
     # Image attachments
-    path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
-    path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
+    path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
+    path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
 
 
     # Change logging
     # Change logging
-    path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
-    path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
+    path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
+    path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
 
 
     # Reports
     # Reports
-    path(r'reports/', views.ReportListView.as_view(), name='report_list'),
-    path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
-    path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
+    path('reports/', views.ReportListView.as_view(), name='report_list'),
+    path('reports/<str:name>/', views.ReportView.as_view(), name='report'),
+    path('reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
 
 
     # Scripts
     # Scripts
-    path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
-    path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
+    path('scripts/', views.ScriptListView.as_view(), name='script_list'),
+    path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
 
 
 ]
 ]

+ 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,

+ 10 - 10
netbox/ipam/api/urls.py

@@ -15,30 +15,30 @@ router = routers.DefaultRouter()
 router.APIRootView = IPAMRootView
 router.APIRootView = IPAMRootView
 
 
 # Field choices
 # Field choices
-router.register(r'_choices', views.IPAMFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.IPAMFieldChoicesViewSet, basename='field-choice')
 
 
 # VRFs
 # VRFs
-router.register(r'vrfs', views.VRFViewSet)
+router.register('vrfs', views.VRFViewSet)
 
 
 # RIRs
 # RIRs
-router.register(r'rirs', views.RIRViewSet)
+router.register('rirs', views.RIRViewSet)
 
 
 # Aggregates
 # Aggregates
-router.register(r'aggregates', views.AggregateViewSet)
+router.register('aggregates', views.AggregateViewSet)
 
 
 # Prefixes
 # Prefixes
-router.register(r'roles', views.RoleViewSet)
-router.register(r'prefixes', views.PrefixViewSet)
+router.register('roles', views.RoleViewSet)
+router.register('prefixes', views.PrefixViewSet)
 
 
 # IP addresses
 # IP addresses
-router.register(r'ip-addresses', views.IPAddressViewSet)
+router.register('ip-addresses', views.IPAddressViewSet)
 
 
 # VLANs
 # VLANs
-router.register(r'vlan-groups', views.VLANGroupViewSet)
-router.register(r'vlans', views.VLANViewSet)
+router.register('vlan-groups', views.VLANGroupViewSet)
+router.register('vlans', views.VLANViewSet)
 
 
 # Services
 # Services
-router.register(r'services', views.ServiceViewSet)
+router.register('services', views.ServiceViewSet)
 
 
 app_name = 'ipam-api'
 app_name = 'ipam-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 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]

+ 176 - 0
netbox/ipam/tests/test_ordering.py

@@ -0,0 +1,176 @@
+from django.test import TestCase
+
+from ipam.choices import IPAddressStatusChoices, PrefixStatusChoices
+from ipam.models import IPAddress, Prefix, VRF
+
+import netaddr
+
+
+class OrderingTestBase(TestCase):
+    vrfs = None
+
+    def setUp(self):
+        """
+        Setup the VRFs for the class as a whole
+        """
+        self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C"))
+        VRF.objects.bulk_create(self.vrfs)
+
+    def _compare(self, queryset, objectset):
+        """
+        Perform the comparison of the queryset object and the object used to instantiate the queryset.
+        """
+        for i, obj in enumerate(queryset):
+            self.assertEqual(obj, objectset[i])
+
+    def _compare_ne(self, queryset, objectset):
+        """
+        Perform the comparison of the queryset object and the object used to instantiate the queryset.
+        """
+        for i, obj in enumerate(queryset):
+            self.assertNotEqual(obj, objectset[i])
+
+
+class PrefixOrderingTestCase(OrderingTestBase):
+
+    def test_prefix_vrf_ordering(self):
+        """
+        This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs
+        """
+        # Setup VRFs
+        vrfa, vrfb, vrfc = self.vrfs
+
+        # Setup Prefixes
+        prefixes = (
+            Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.1.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.2.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.3.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.4.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.5.0/24')),
+
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.2.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.3.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.4.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.2.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.3.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.4.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.1.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.2.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.3.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.4.0/24')),
+
+            Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/12')),
+            Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.1.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.2.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.3.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.4.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.1.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.2.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.3.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.4.0/24')),
+        )
+
+        Prefix.objects.bulk_create(prefixes)
+
+        # Test
+        self._compare(Prefix.objects.all(), prefixes)
+
+    def test_prefix_complex_ordering(self):
+        """
+        This function tests a complex ordering of interwoven prefixes and vrfs.  This is the current expected ordering of VRFs
+        This includes the testing of the Container status.
+
+        The proper ordering, to get proper containerization should be:
+            None:10.0.0.0/8
+            None:10.0.0.0/16
+            VRF A:10.0.0.0/24
+            VRF A:10.0.1.0/24
+            VRF A:10.0.1.0/25
+            None:10.1.0.0/16
+            VRF A:10.1.0.0/24
+            VRF A:10.1.1.0/24
+            None: 192.168.0.0/16
+        """
+        # Setup VRFs
+        vrfa, vrfb, vrfc = self.vrfs
+
+        # Setup Prefixes
+        prefixes = [
+            Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')),
+            Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/25')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/24')),
+            Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')),
+        ]
+        Prefix.objects.bulk_create(prefixes)
+
+        # Test
+        self._compare(Prefix.objects.all(), prefixes)
+
+
+class IPAddressOrderingTestCase(OrderingTestBase):
+
+    def test_address_vrf_ordering(self):
+        """
+        This function tests ordering with the inclusion of vrfs
+        """
+        # Setup VRFs
+        vrfa, vrfb, vrfc = self.vrfs
+
+        # Setup Addresses
+        addresses = (
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.0.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.1.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.2.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.3.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.4.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.0.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.1.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.2.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.3.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.4.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.0.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.1.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.2.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.3.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.4.1/24')),
+
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.0.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.1.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.2.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.3.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.4.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.0.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.1.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.2.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.3.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.4.1/24')),
+
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.0.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.1.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.2.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.3.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.4.1/24')),
+            IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.5.1/24')),
+        )
+        IPAddress.objects.bulk_create(addresses)
+
+        # Test
+        self._compare(IPAddress.objects.all(), addresses)

+ 10 - 25
netbox/ipam/tests/test_views.py

@@ -5,10 +5,10 @@ from netaddr import IPNetwork
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.choices import *
 from ipam.choices import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
-from utilities.testing import StandardTestCases
+from utilities.testing import ViewTestCases
 
 
 
 
-class VRFTestCase(StandardTestCases.Views):
+class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VRF
     model = VRF
 
 
     @classmethod
     @classmethod
@@ -43,14 +43,9 @@ class VRFTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class RIRTestCase(StandardTestCases.Views):
+class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = RIR
     model = RIR
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -74,7 +69,7 @@ class RIRTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class AggregateTestCase(StandardTestCases.Views):
+class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Aggregate
     model = Aggregate
 
 
     @classmethod
     @classmethod
@@ -115,14 +110,9 @@ class AggregateTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class RoleTestCase(StandardTestCases.Views):
+class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = Role
     model = Role
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -147,7 +137,7 @@ class RoleTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class PrefixTestCase(StandardTestCases.Views):
+class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Prefix
     model = Prefix
 
 
     @classmethod
     @classmethod
@@ -207,7 +197,7 @@ class PrefixTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class IPAddressTestCase(StandardTestCases.Views):
+class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = IPAddress
     model = IPAddress
 
 
     @classmethod
     @classmethod
@@ -254,14 +244,9 @@ class IPAddressTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class VLANGroupTestCase(StandardTestCases.Views):
+class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = VLANGroup
     model = VLANGroup
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -287,7 +272,7 @@ class VLANGroupTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class VLANTestCase(StandardTestCases.Views):
+class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VLAN
     model = VLAN
 
 
     @classmethod
     @classmethod
@@ -346,7 +331,7 @@ class VLANTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class ServiceTestCase(StandardTestCases.Views):
+class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Service
     model = Service
 
 
     # Disable inapplicable tests
     # Disable inapplicable tests

+ 76 - 76
netbox/ipam/urls.py

@@ -8,97 +8,97 @@ app_name = 'ipam'
 urlpatterns = [
 urlpatterns = [
 
 
     # VRFs
     # VRFs
-    path(r'vrfs/', views.VRFListView.as_view(), name='vrf_list'),
-    path(r'vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'),
-    path(r'vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
-    path(r'vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
-    path(r'vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
-    path(r'vrfs/<int:pk>/', views.VRFView.as_view(), name='vrf'),
-    path(r'vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
-    path(r'vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
-    path(r'vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
+    path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
+    path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'),
+    path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
+    path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
+    path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
+    path('vrfs/<int:pk>/', views.VRFView.as_view(), name='vrf'),
+    path('vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
+    path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
+    path('vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
 
 
     # RIRs
     # RIRs
-    path(r'rirs/', views.RIRListView.as_view(), name='rir_list'),
-    path(r'rirs/add/', views.RIRCreateView.as_view(), name='rir_add'),
-    path(r'rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
-    path(r'rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
-    path(r'rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
-    path(r'vrfs/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
+    path('rirs/', views.RIRListView.as_view(), name='rir_list'),
+    path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'),
+    path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
+    path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
+    path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
+    path('vrfs/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
 
 
     # Aggregates
     # Aggregates
-    path(r'aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
-    path(r'aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'),
-    path(r'aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
-    path(r'aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
-    path(r'aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
-    path(r'aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
-    path(r'aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
-    path(r'aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
-    path(r'aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
+    path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
+    path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'),
+    path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
+    path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
+    path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
+    path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
+    path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
+    path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
+    path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
 
 
     # Roles
     # Roles
-    path(r'roles/', views.RoleListView.as_view(), name='role_list'),
-    path(r'roles/add/', views.RoleCreateView.as_view(), name='role_add'),
-    path(r'roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
-    path(r'roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
-    path(r'roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
-    path(r'roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
+    path('roles/', views.RoleListView.as_view(), name='role_list'),
+    path('roles/add/', views.RoleCreateView.as_view(), name='role_add'),
+    path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
+    path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
+    path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
+    path('roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
 
 
     # Prefixes
     # Prefixes
-    path(r'prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
-    path(r'prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'),
-    path(r'prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
-    path(r'prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
-    path(r'prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
-    path(r'prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
-    path(r'prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
-    path(r'prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
-    path(r'prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
-    path(r'prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
-    path(r'prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
+    path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
+    path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'),
+    path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
+    path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
+    path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
+    path('prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
+    path('prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
+    path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
+    path('prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
+    path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
+    path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
 
 
     # IP addresses
     # IP addresses
-    path(r'ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
-    path(r'ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
-    path(r'ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
-    path(r'ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
-    path(r'ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
-    path(r'ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
-    path(r'ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
-    path(r'ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
-    path(r'ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
-    path(r'ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
-    path(r'ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
+    path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
+    path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
+    path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
+    path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
+    path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
+    path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
+    path('ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
+    path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
+    path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
+    path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
+    path('ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
 
 
     # VLAN groups
     # VLAN groups
-    path(r'vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
-    path(r'vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
-    path(r'vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
-    path(r'vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
-    path(r'vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
-    path(r'vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
-    path(r'vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
+    path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
+    path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
+    path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
+    path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
+    path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
+    path('vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
+    path('vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
 
 
     # VLANs
     # VLANs
-    path(r'vlans/', views.VLANListView.as_view(), name='vlan_list'),
-    path(r'vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'),
-    path(r'vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
-    path(r'vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
-    path(r'vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
-    path(r'vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
-    path(r'vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
-    path(r'vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
-    path(r'vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
-    path(r'vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
+    path('vlans/', views.VLANListView.as_view(), name='vlan_list'),
+    path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'),
+    path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
+    path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
+    path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
+    path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
+    path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
+    path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
+    path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
+    path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
 
 
     # Services
     # Services
-    path(r'services/', views.ServiceListView.as_view(), name='service_list'),
-    path(r'services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
-    path(r'services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
-    path(r'services/<int:pk>/', views.ServiceView.as_view(), name='service'),
-    path(r'services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
-    path(r'services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
-    path(r'services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
+    path('services/', views.ServiceListView.as_view(), name='service_list'),
+    path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
+    path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
+    path('services/<int:pk>/', views.ServiceView.as_view(), name='service'),
+    path('services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
+    path('services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
+    path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
 
 
 ]
 ]

+ 8 - 1
netbox/netbox/configuration.example.py

@@ -10,7 +10,8 @@
 # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
 # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
 ALLOWED_HOSTS = []
 ALLOWED_HOSTS = []
 
 
-# PostgreSQL database configuration.
+# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
+#   https://docs.djangoproject.com/en/stable/ref/settings/#databases
 DATABASE = {
 DATABASE = {
     'NAME': 'netbox',         # Database name
     'NAME': 'netbox',         # Database name
     'USER': '',               # PostgreSQL username
     'USER': '',               # PostgreSQL username
@@ -27,6 +28,9 @@ REDIS = {
     'webhooks': {
     'webhooks': {
         'HOST': 'localhost',
         'HOST': 'localhost',
         'PORT': 6379,
         'PORT': 6379,
+        # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
+        # 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
+        # 'SENTINEL_SERVICE': 'netbox',
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 0,
         'DATABASE': 0,
         'DEFAULT_TIMEOUT': 300,
         'DEFAULT_TIMEOUT': 300,
@@ -35,6 +39,9 @@ REDIS = {
     'caching': {
     'caching': {
         'HOST': 'localhost',
         'HOST': 'localhost',
         'PORT': 6379,
         'PORT': 6379,
+        # Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
+        # 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
+        # 'SENTINEL_SERVICE': 'netbox',
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 1,
         'DATABASE': 1,
         'DEFAULT_TIMEOUT': 300,
         'DEFAULT_TIMEOUT': 300,

+ 51 - 22
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.7.4'
+VERSION = '2.7.5'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -170,18 +170,31 @@ if 'caching' not in REDIS:
 WEBHOOKS_REDIS = REDIS.get('webhooks', {})
 WEBHOOKS_REDIS = REDIS.get('webhooks', {})
 WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
 WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
 WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
 WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
+WEBHOOKS_REDIS_SENTINELS = WEBHOOKS_REDIS.get('SENTINELS', [])
+WEBHOOKS_REDIS_USING_SENTINEL = all([
+    isinstance(WEBHOOKS_REDIS_SENTINELS, (list, tuple)),
+    len(WEBHOOKS_REDIS_SENTINELS) > 0
+])
+WEBHOOKS_REDIS_SENTINEL_SERVICE = WEBHOOKS_REDIS.get('SENTINEL_SERVICE', 'default')
 WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
 WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
 WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
 WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
 WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
 WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
 WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
 WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
 
 
+
 CACHING_REDIS = REDIS.get('caching', {})
 CACHING_REDIS = REDIS.get('caching', {})
-CACHING_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
-CACHING_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
-CACHING_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
-CACHING_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
-CACHING_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
-CACHING_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
+CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost')
+CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379)
+CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', [])
+CACHING_REDIS_USING_SENTINEL = all([
+    isinstance(CACHING_REDIS_SENTINELS, (list, tuple)),
+    len(CACHING_REDIS_SENTINELS) > 0
+])
+CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
+CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
+CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
+CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300)
+CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
 
 
 
 
 #
 #
@@ -394,28 +407,35 @@ if LDAP_CONFIG is not None:
 #
 #
 # Caching
 # Caching
 #
 #
-
-if CACHING_REDIS_SSL:
-    REDIS_CACHE_CON_STRING = 'rediss://'
+if CACHING_REDIS_USING_SENTINEL:
+    CACHEOPS_SENTINEL = {
+        'locations': CACHING_REDIS_SENTINELS,
+        'service_name': CACHING_REDIS_SENTINEL_SERVICE,
+        'db': CACHING_REDIS_DATABASE,
+    }
 else:
 else:
-    REDIS_CACHE_CON_STRING = 'redis://'
-
-if CACHING_REDIS_PASSWORD:
-    REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
-
-REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
-    REDIS_CACHE_CON_STRING,
-    CACHING_REDIS_HOST,
-    CACHING_REDIS_PORT,
-    CACHING_REDIS_DATABASE
-)
+    if CACHING_REDIS_SSL:
+        REDIS_CACHE_CON_STRING = 'rediss://'
+    else:
+        REDIS_CACHE_CON_STRING = 'redis://'
+
+    if CACHING_REDIS_PASSWORD:
+        REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
+
+    REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
+        REDIS_CACHE_CON_STRING,
+        CACHING_REDIS_HOST,
+        CACHING_REDIS_PORT,
+        CACHING_REDIS_DATABASE
+    )
+    CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
 
 
 if not CACHE_TIMEOUT:
 if not CACHE_TIMEOUT:
     CACHEOPS_ENABLED = False
     CACHEOPS_ENABLED = False
 else:
 else:
     CACHEOPS_ENABLED = True
     CACHEOPS_ENABLED = True
 
 
-CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
+
 CACHEOPS_DEFAULTS = {
 CACHEOPS_DEFAULTS = {
     'timeout': CACHE_TIMEOUT
     'timeout': CACHE_TIMEOUT
 }
 }
@@ -534,6 +554,15 @@ RQ_QUEUES = {
         'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
         'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
         'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT,
         'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT,
         'SSL': WEBHOOKS_REDIS_SSL,
         'SSL': WEBHOOKS_REDIS_SSL,
+    } if not WEBHOOKS_REDIS_USING_SENTINEL else {
+        'SENTINELS': WEBHOOKS_REDIS_SENTINELS,
+        'MASTER_NAME': WEBHOOKS_REDIS_SENTINEL_SERVICE,
+        'DB': WEBHOOKS_REDIS_DATABASE,
+        'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
+        'SOCKET_TIMEOUT': None,
+        'CONNECTION_KWARGS': {
+            'socket_connect_timeout': WEBHOOKS_REDIS_DEFAULT_TIMEOUT
+        },
     }
     }
 }
 }
 
 

+ 27 - 27
netbox/netbox/urls.py

@@ -26,49 +26,49 @@ schema_view = get_schema_view(
 _patterns = [
 _patterns = [
 
 
     # Base views
     # Base views
-    path(r'', HomeView.as_view(), name='home'),
-    path(r'search/', SearchView.as_view(), name='search'),
+    path('', HomeView.as_view(), name='home'),
+    path('search/', SearchView.as_view(), name='search'),
 
 
     # Login/logout
     # Login/logout
-    path(r'login/', LoginView.as_view(), name='login'),
-    path(r'logout/', LogoutView.as_view(), name='logout'),
+    path('login/', LoginView.as_view(), name='login'),
+    path('logout/', LogoutView.as_view(), name='logout'),
 
 
     # Apps
     # Apps
-    path(r'circuits/', include('circuits.urls')),
-    path(r'dcim/', include('dcim.urls')),
-    path(r'extras/', include('extras.urls')),
-    path(r'ipam/', include('ipam.urls')),
-    path(r'secrets/', include('secrets.urls')),
-    path(r'tenancy/', include('tenancy.urls')),
-    path(r'user/', include('users.urls')),
-    path(r'virtualization/', include('virtualization.urls')),
+    path('circuits/', include('circuits.urls')),
+    path('dcim/', include('dcim.urls')),
+    path('extras/', include('extras.urls')),
+    path('ipam/', include('ipam.urls')),
+    path('secrets/', include('secrets.urls')),
+    path('tenancy/', include('tenancy.urls')),
+    path('user/', include('users.urls')),
+    path('virtualization/', include('virtualization.urls')),
 
 
     # API
     # API
-    path(r'api/', APIRootView.as_view(), name='api-root'),
-    path(r'api/circuits/', include('circuits.api.urls')),
-    path(r'api/dcim/', include('dcim.api.urls')),
-    path(r'api/extras/', include('extras.api.urls')),
-    path(r'api/ipam/', include('ipam.api.urls')),
-    path(r'api/secrets/', include('secrets.api.urls')),
-    path(r'api/tenancy/', include('tenancy.api.urls')),
-    path(r'api/virtualization/', include('virtualization.api.urls')),
-    path(r'api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
-    path(r'api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
+    path('api/', APIRootView.as_view(), name='api-root'),
+    path('api/circuits/', include('circuits.api.urls')),
+    path('api/dcim/', include('dcim.api.urls')),
+    path('api/extras/', include('extras.api.urls')),
+    path('api/ipam/', include('ipam.api.urls')),
+    path('api/secrets/', include('secrets.api.urls')),
+    path('api/tenancy/', include('tenancy.api.urls')),
+    path('api/virtualization/', include('virtualization.api.urls')),
+    path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
+    path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
     re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
     re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
 
 
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
-    path(r'media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
+    path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
 
 
     # Admin
     # Admin
-    path(r'admin/', admin_site.urls),
-    path(r'admin/webhook-backend-status/', include('django_rq.urls')),
+    path('admin/', admin_site.urls),
+    path('admin/webhook-backend-status/', include('django_rq.urls')),
 
 
 ]
 ]
 
 
 if settings.DEBUG:
 if settings.DEBUG:
     import debug_toolbar
     import debug_toolbar
     _patterns += [
     _patterns += [
-        path(r'__debug__/', include(debug_toolbar.urls)),
+        path('__debug__/', include(debug_toolbar.urls)),
     ]
     ]
 
 
 if settings.METRICS_ENABLED:
 if settings.METRICS_ENABLED:
@@ -78,7 +78,7 @@ if settings.METRICS_ENABLED:
 
 
 # Prepend BASE_PATH
 # Prepend BASE_PATH
 urlpatterns = [
 urlpatterns = [
-    path(r'{}'.format(settings.BASE_PATH), include(_patterns))
+    path('{}'.format(settings.BASE_PATH), include(_patterns))
 ]
 ]
 
 
 handler500 = 'utilities.views.server_error'
 handler500 = 'utilities.views.server_error'

+ 1 - 1
netbox/netbox/views.py

@@ -252,7 +252,7 @@ class HomeView(View):
             'search_form': SearchForm(),
             'search_form': SearchForm(),
             'stats': stats,
             'stats': stats,
             'report_results': ReportResult.objects.order_by('-created')[:10],
             'report_results': ReportResult.objects.order_by('-created')[:10],
-            'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:50]
+            'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:15]
         })
         })
 
 
 
 

+ 12 - 0
netbox/project-static/css/base.css

@@ -62,8 +62,20 @@ footer p {
     }
     }
 }
 }
 
 
+/* Scroll the drop-down menus at or above 768px wide to match bootstrap's behavior for hiding dropdown menus */
+@media (min-width: 768px) {
+    .navbar-nav>li>ul {
+        max-height: calc(80vh - 50px);
+        overflow-y: auto;
+    }
+}
+
 /* Collapse the nav menu on displays less than 980px wide */
 /* Collapse the nav menu on displays less than 980px wide */
 @media (max-width: 979px) {
 @media (max-width: 979px) {
+    #navbar {
+        max-height: calc(80vh - 50px);
+        overflow-y: auto;
+    }
     .navbar-header {
     .navbar-header {
         float: none;
         float: none;
     }
     }

+ 9 - 0
netbox/project-static/css/rack_elevation.css

@@ -56,3 +56,12 @@ text {
 .blocked:hover+.add-device {
 .blocked:hover+.add-device {
     fill: none;
     fill: none;
 }
 }
+
+.unit {
+    margin: 0;
+    padding: 5px 0px;
+
+    fill: #c0c0c0;
+    font-size: 10px;
+    font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
+}

+ 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'
                     });
                     });
                 }
                 }
 
 

+ 5 - 5
netbox/secrets/api/urls.py

@@ -15,15 +15,15 @@ router = routers.DefaultRouter()
 router.APIRootView = SecretsRootView
 router.APIRootView = SecretsRootView
 
 
 # Field choices
 # Field choices
-router.register(r'_choices', views.SecretsFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.SecretsFieldChoicesViewSet, basename='field-choice')
 
 
 # Secrets
 # Secrets
-router.register(r'secret-roles', views.SecretRoleViewSet)
-router.register(r'secrets', views.SecretViewSet)
+router.register('secret-roles', views.SecretRoleViewSet)
+router.register('secrets', views.SecretViewSet)
 
 
 # Miscellaneous
 # Miscellaneous
-router.register(r'get-session-key', views.GetSessionKeyViewSet, basename='get-session-key')
-router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair')
+router.register('get-session-key', views.GetSessionKeyViewSet, basename='get-session-key')
+router.register('generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair')
 
 
 app_name = 'secrets-api'
 app_name = 'secrets-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 5 - 3
netbox/secrets/api/views.py

@@ -93,8 +93,8 @@ class SecretViewSet(ModelViewSet):
 
 
         secret = self.get_object()
         secret = self.get_object()
 
 
-        # Attempt to decrypt the secret if the master key is known
-        if self.master_key is not None:
+        # Attempt to decrypt the secret if the user is permitted and the master key is known
+        if secret.decryptable_by(request.user) and self.master_key is not None:
             secret.decrypt(self.master_key)
             secret.decrypt(self.master_key)
 
 
         serializer = self.get_serializer(secret)
         serializer = self.get_serializer(secret)
@@ -111,7 +111,9 @@ class SecretViewSet(ModelViewSet):
             if self.master_key is not None:
             if self.master_key is not None:
                 secrets = []
                 secrets = []
                 for secret in page:
                 for secret in page:
-                    secret.decrypt(self.master_key)
+                    # Enforce role permissions
+                    if secret.decryptable_by(request.user):
+                        secret.decrypt(self.master_key)
                     secrets.append(secret)
                     secrets.append(secret)
                 serializer = self.get_serializer(secrets, many=True)
                 serializer = self.get_serializer(secrets, many=True)
             else:
             else:

+ 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",

+ 36 - 11
netbox/secrets/tests/test_api.py

@@ -5,7 +5,8 @@ from rest_framework import status
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.testing import APITestCase
+from users.models import Token
+from utilities.testing import APITestCase, create_test_user
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 
 
 
 
@@ -131,7 +132,15 @@ class SecretTest(APITestCase):
 
 
     def setUp(self):
     def setUp(self):
 
 
-        super().setUp()
+        # Create a non-superuser test user
+        self.user = create_test_user('testuser', permissions=(
+            'secrets.add_secret',
+            'secrets.change_secret',
+            'secrets.delete_secret',
+            'secrets.view_secret',
+        ))
+        self.token = Token.objects.create(user=self.user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
 
 
         userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
         userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
         userkey.save()
         userkey.save()
@@ -144,11 +153,11 @@ class SecretTest(APITestCase):
             'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key),
             'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key),
         }
         }
 
 
-        self.plaintext = {
-            'secret1': 'Secret #1 Plaintext',
-            'secret2': 'Secret #2 Plaintext',
-            'secret3': 'Secret #3 Plaintext',
-        }
+        self.plaintexts = (
+            'Secret #1 Plaintext',
+            'Secret #2 Plaintext',
+            'Secret #3 Plaintext',
+        )
 
 
         site = Site.objects.create(name='Test Site 1', slug='test-site-1')
         site = Site.objects.create(name='Test Site 1', slug='test-site-1')
         manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -160,17 +169,17 @@ class SecretTest(APITestCase):
         self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1')
         self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1')
         self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
         self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
         self.secret1 = Secret(
         self.secret1 = Secret(
-            device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1']
+            device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintexts[0]
         )
         )
         self.secret1.encrypt(self.master_key)
         self.secret1.encrypt(self.master_key)
         self.secret1.save()
         self.secret1.save()
         self.secret2 = Secret(
         self.secret2 = Secret(
-            device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2']
+            device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintexts[1]
         )
         )
         self.secret2.encrypt(self.master_key)
         self.secret2.encrypt(self.master_key)
         self.secret2.save()
         self.secret2.save()
         self.secret3 = Secret(
         self.secret3 = Secret(
-            device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3']
+            device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintexts[2]
         )
         )
         self.secret3.encrypt(self.master_key)
         self.secret3.encrypt(self.master_key)
         self.secret3.save()
         self.secret3.save()
@@ -178,16 +187,32 @@ class SecretTest(APITestCase):
     def test_get_secret(self):
     def test_get_secret(self):
 
 
         url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
         url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
+
+        # Secret plaintext not be decrypted as the user has not been assigned to the role
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
+        self.assertIsNone(response.data['plaintext'])
 
 
-        self.assertEqual(response.data['plaintext'], self.plaintext['secret1'])
+        # The plaintext should be present once the user has been assigned to the role
+        self.secretrole1.users.add(self.user)
+        response = self.client.get(url, **self.header)
+        self.assertEqual(response.data['plaintext'], self.plaintexts[0])
 
 
     def test_list_secrets(self):
     def test_list_secrets(self):
 
 
         url = reverse('secrets-api:secret-list')
         url = reverse('secrets-api:secret-list')
+
+        # Secret plaintext not be decrypted as the user has not been assigned to the role
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
+        self.assertEqual(response.data['count'], 3)
+        for secret in response.data['results']:
+            self.assertIsNone(secret['plaintext'])
 
 
+        # The plaintext should be present once the user has been assigned to the role
+        self.secretrole1.users.add(self.user)
+        response = self.client.get(url, **self.header)
         self.assertEqual(response.data['count'], 3)
         self.assertEqual(response.data['count'], 3)
+        for i, secret in enumerate(response.data['results']):
+            self.assertEqual(secret['plaintext'], self.plaintexts[i])
 
 
     def test_create_secret(self):
     def test_create_secret(self):
 
 

+ 3 - 8
netbox/secrets/tests/test_views.py

@@ -4,18 +4,13 @@ from django.urls import reverse
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.testing import StandardTestCases
+from utilities.testing import ViewTestCases
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 
 
 
 
-class SecretRoleTestCase(StandardTestCases.Views):
+class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = SecretRole
     model = SecretRole
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -41,7 +36,7 @@ class SecretRoleTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class SecretTestCase(StandardTestCases.Views):
+class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Secret
     model = Secret
 
 
     # Disable inapplicable tests
     # Disable inapplicable tests

+ 14 - 14
netbox/secrets/urls.py

@@ -8,21 +8,21 @@ app_name = 'secrets'
 urlpatterns = [
 urlpatterns = [
 
 
     # Secret roles
     # Secret roles
-    path(r'secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'),
-    path(r'secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
-    path(r'secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
-    path(r'secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
-    path(r'secret-roles/<slug:slug>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
-    path(r'secret-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
+    path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'),
+    path('secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
+    path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
+    path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
+    path('secret-roles/<slug:slug>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
+    path('secret-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
 
 
     # Secrets
     # Secrets
-    path(r'secrets/', views.SecretListView.as_view(), name='secret_list'),
-    path(r'secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'),
-    path(r'secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
-    path(r'secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
-    path(r'secrets/<int:pk>/', views.SecretView.as_view(), name='secret'),
-    path(r'secrets/<int:pk>/edit/', views.secret_edit, name='secret_edit'),
-    path(r'secrets/<int:pk>/delete/', views.SecretDeleteView.as_view(), name='secret_delete'),
-    path(r'secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
+    path('secrets/', views.SecretListView.as_view(), name='secret_list'),
+    path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'),
+    path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
+    path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
+    path('secrets/<int:pk>/', views.SecretView.as_view(), name='secret'),
+    path('secrets/<int:pk>/edit/', views.secret_edit, name='secret_edit'),
+    path('secrets/<int:pk>/delete/', views.SecretDeleteView.as_view(), name='secret_delete'),
+    path('secrets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
 
 
 ]
 ]

+ 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 %}

+ 49 - 29
netbox/templates/dcim/device.html

@@ -48,14 +48,30 @@
                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
                 </button>
                 </button>
                 <ul class="dropdown-menu">
                 <ul class="dropdown-menu">
-                    {% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:consoleport_add' pk=device.pk %}">Console Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}">Console Server Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:powerport_add' pk=device.pk %}">Power Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}">Power Outlets</a></li>{% endif %}
-                    {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:interface_add' pk=device.pk %}">Interfaces</a></li>{% endif %}
-                    {% if perms.dcim.add_frontport %}<li><a href="{% url 'dcim:frontport_add' pk=device.pk %}">Front Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_rearport %}<li><a href="{% url 'dcim:rearport_add' pk=device.pk %}">Rear Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:devicebay_add' pk=device.pk %}">Device Bays</a></li>{% endif %}
+                    {% if perms.dcim.add_consoleport %}
+                        <li><a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Console Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_consoleserverport %}
+                        <li><a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Console Server Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_powerport %}
+                        <li><a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Power Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_poweroutlet %}
+                        <li><a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Power Outlets</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_interface %}
+                        <li><a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Interfaces</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_frontport %}
+                        <li><a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Front Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_rearport %}
+                        <li><a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Rear Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_devicebay %}
+                        <li><a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Device Bays</a></li>
+                    {% endif %}
                 </ul>
                 </ul>
             </div>
             </div>
         {% endif %}
         {% endif %}
@@ -333,12 +349,12 @@
                     {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
                     {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
                         <div class="panel-footer text-right noprint">
                         <div class="panel-footer text-right noprint">
                             {% if perms.dcim.add_consoleport %}
                             {% if perms.dcim.add_consoleport %}
-                                <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                <a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
                                 </a>
                                 </a>
                             {% endif %}
                             {% endif %}
                             {% if perms.dcim.add_powerport %}
                             {% if perms.dcim.add_powerport %}
-                                <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                <a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
                                 </a>
                                 </a>
                             {% endif %}
                             {% endif %}
@@ -524,13 +540,13 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if device_bays and perms.dcim.delete_devicebay %}
                         {% if device_bays and perms.dcim.delete_devicebay %}
-                            <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' pk=device.pk  %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_devicebay %}
                         {% if perms.dcim.add_devicebay %}
                             <div class="pull-right">
                             <div class="pull-right">
-                                <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
                                 </a>
                                 </a>
                             </div>
                             </div>
@@ -587,7 +603,7 @@
                             <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </button>
                             </button>
-                            <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
@@ -597,13 +613,13 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if interfaces and perms.dcim.delete_interface %}
                         {% if interfaces and perms.dcim.delete_interface %}
-                            <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_interface %}
                         {% if perms.dcim.add_interface %}
                             <div class="pull-right">
                             <div class="pull-right">
-                                <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                 </a>
                                 </a>
                             </div>
                             </div>
@@ -619,6 +635,7 @@
                 {% if perms.dcim.delete_consoleserverport %}
                 {% if perms.dcim.delete_consoleserverport %}
                     <form method="post">
                     <form method="post">
                     {% csrf_token %}
                     {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                 {% endif %}
                 {% endif %}
                 <div class="panel panel-default">
                 <div class="panel panel-default">
                     <div class="panel-heading">
                     <div class="panel-heading">
@@ -649,7 +666,7 @@
                             <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </button>
                             </button>
-                            <button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             </button>
                             </button>
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -657,13 +674,13 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if consoleserverports and perms.dcim.delete_consoleserverport %}
                         {% if consoleserverports and perms.dcim.delete_consoleserverport %}
-                            <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_consoleserverport %}
                         {% if perms.dcim.add_consoleserverport %}
                             <div class="pull-right">
                             <div class="pull-right">
-                                <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
                                 </a>
                                 </a>
                             </div>
                             </div>
@@ -679,6 +696,7 @@
                 {% if perms.dcim.delete_poweroutlet %}
                 {% if perms.dcim.delete_poweroutlet %}
                     <form method="post">
                     <form method="post">
                     {% csrf_token %}
                     {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                 {% endif %}
                 {% endif %}
                 <div class="panel panel-default">
                 <div class="panel panel-default">
                     <div class="panel-heading">
                     <div class="panel-heading">
@@ -710,7 +728,7 @@
                             <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </button>
                             </button>
-                            <button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             </button>
                             </button>
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -718,13 +736,13 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if poweroutlets and perms.dcim.delete_poweroutlet %}
                         {% if poweroutlets and perms.dcim.delete_poweroutlet %}
-                            <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_poweroutlet %}
                         {% if perms.dcim.add_poweroutlet %}
                             <div class="pull-right">
                             <div class="pull-right">
-                                <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
                                 </a>
                                 </a>
                             </div>
                             </div>
@@ -738,7 +756,8 @@
             {% endif %}
             {% endif %}
             {% if front_ports %}
             {% if front_ports %}
                 <form method="post">
                 <form method="post">
-                {% csrf_token %}
+                    {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                     <div class="panel panel-default">
                     <div class="panel panel-default">
                         <div class="panel-heading">
                         <div class="panel-heading">
                             <strong>Front Ports</strong>
                             <strong>Front Ports</strong>
@@ -770,7 +789,7 @@
                                 <button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 </button>
                                 </button>
-                                <button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 </button>
                                 </button>
                                 <button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -778,13 +797,13 @@
                                 </button>
                                 </button>
                             {% endif %}
                             {% endif %}
                             {% if front_ports and perms.dcim.delete_frontport %}
                             {% if front_ports and perms.dcim.delete_frontport %}
-                                <button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                                <button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                     <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                     <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 </button>
                                 </button>
                             {% endif %}
                             {% endif %}
                             {% if perms.dcim.add_frontport %}
                             {% if perms.dcim.add_frontport %}
                                 <div class="pull-right">
                                 <div class="pull-right">
-                                    <a href="{% url 'dcim:frontport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front ports
                                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front ports
                                     </a>
                                     </a>
                                 </div>
                                 </div>
@@ -796,7 +815,8 @@
             {% endif %}
             {% endif %}
             {% if rear_ports %}
             {% if rear_ports %}
                 <form method="post">
                 <form method="post">
-                {% csrf_token %}
+                    {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                     <div class="panel panel-default">
                     <div class="panel panel-default">
                         <div class="panel-heading">
                         <div class="panel-heading">
                             <strong>Rear Ports</strong>
                             <strong>Rear Ports</strong>
@@ -827,7 +847,7 @@
                                 <button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 </button>
                                 </button>
-                                <button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 </button>
                                 </button>
                                 <button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -835,13 +855,13 @@
                                 </button>
                                 </button>
                             {% endif %}
                             {% endif %}
                             {% if rear_ports and perms.dcim.delete_rearport %}
                             {% if rear_ports and perms.dcim.delete_rearport %}
-                                <button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                                <button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                     <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                     <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 </button>
                                 </button>
                             {% endif %}
                             {% endif %}
                             {% if perms.dcim.add_rearport %}
                             {% if perms.dcim.add_rearport %}
                                 <div class="pull-right">
                                 <div class="pull-right">
-                                    <a href="{% url 'dcim:rearport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear ports
                                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear ports
                                     </a>
                                     </a>
                                 </div>
                                 </div>

+ 2 - 11
netbox/templates/dcim/device_component_add.html

@@ -1,14 +1,11 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
-{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
+{% block title %}Create {{ component_type }}{% endblock %}
 
 
 {% block content %}
 {% block content %}
-<form action="." method="post" class="form form-horizontal">
+<form action="" method="post" class="form form-horizontal">
     {% csrf_token %}
     {% csrf_token %}
-    {% for field in form.hidden_fields %}
-        {{ field }}
-    {% endfor %}
     <div class="row">
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
         <div class="col-md-6 col-md-offset-3">
             {% if form.non_field_errors %}
             {% if form.non_field_errors %}
@@ -24,12 +21,6 @@
                     <strong>{{ component_type|title }}</strong>
                     <strong>{{ component_type|title }}</strong>
                 </div>
                 </div>
                 <div class="panel-body">
                 <div class="panel-body">
-                    <div class="form-group">
-                        <label class="col-md-3 control-label required">Device</label>
-                        <div class="col-md-9">
-                            <p class="form-control-static">{{ parent }}</p>
-                        </div>
-                    </div>
                     {% render_form form %}
                     {% render_form form %}
                 </div>
                 </div>
             </div>
             </div>

+ 1 - 1
netbox/templates/dcim/device_inventory.html

@@ -54,7 +54,7 @@
                 </table>
                 </table>
                 {% if perms.dcim.add_inventoryitem %}
                 {% if perms.dcim.add_inventoryitem %}
                     <div class="panel-footer text-right noprint">
                     <div class="panel-footer text-right noprint">
-                        <a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
+                        <a href="{% url 'dcim:inventoryitem_add' %}?device={{ device.pk }}&return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-primary btn-xs">
                             <span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
                             <span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
                         </a>
                         </a>
                     </div>
                     </div>

+ 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 %}

+ 16 - 16
netbox/templates/dcim/devicetype.html

@@ -22,14 +22,14 @@
                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
                 </button>
                 </button>
                 <ul class="dropdown-menu">
                 <ul class="dropdown-menu">
-                    {% if perms.dcim.add_consoleporttemplate %}<li><a href="{% url 'dcim:devicetype_add_consoleport' pk=devicetype.pk %}">Console Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_consoleserverporttemplate %}<li><a href="{% url 'dcim:devicetype_add_consoleserverport' pk=devicetype.pk %}">Console Server Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_powerporttemplate %}<li><a href="{% url 'dcim:devicetype_add_powerport' pk=devicetype.pk %}">Power Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_poweroutlettemplate %}<li><a href="{% url 'dcim:devicetype_add_poweroutlet' pk=devicetype.pk %}">Power Outlets</a></li>{% endif %}
-                    {% if perms.dcim.add_interfacetemplate %}<li><a href="{% url 'dcim:devicetype_add_interface' pk=devicetype.pk %}">Interfaces</a></li>{% endif %}
-                    {% if perms.dcim.add_frontporttemplate %}<li><a href="{% url 'dcim:devicetype_add_frontport' pk=devicetype.pk %}">Front Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_rearporttemplate %}<li><a href="{% url 'dcim:devicetype_add_rearport' pk=devicetype.pk %}">Rear Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_devicebaytemplate %}<li><a href="{% url 'dcim:devicetype_add_devicebay' pk=devicetype.pk %}">Device Bays</a></li>{% endif %}
+                    {% if perms.dcim.add_consoleporttemplate %}<li><a href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Console Ports</a></li>{% endif %}
+                    {% if perms.dcim.add_consoleserverporttemplate %}<li><a href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Console Server Ports</a></li>{% endif %}
+                    {% if perms.dcim.add_powerporttemplate %}<li><a href="{% url 'dcim:powerporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Power Ports</a></li>{% endif %}
+                    {% if perms.dcim.add_poweroutlettemplate %}<li><a href="{% url 'dcim:poweroutlettemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Power Outlets</a></li>{% endif %}
+                    {% if perms.dcim.add_interfacetemplate %}<li><a href="{% url 'dcim:interfacetemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Interfaces</a></li>{% endif %}
+                    {% if perms.dcim.add_frontporttemplate %}<li><a href="{% url 'dcim:frontporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Front Ports</a></li>{% endif %}
+                    {% if perms.dcim.add_rearporttemplate %}<li><a href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Rear Ports</a></li>{% endif %}
+                    {% if perms.dcim.add_devicebaytemplate %}<li><a href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Device Bays</a></li>{% endif %}
                 </ul>
                 </ul>
             </div>
             </div>
         {% endif %}
         {% endif %}
@@ -136,48 +136,48 @@
 {% if devicetype.consoleport_templates.exists or devicetype.powerport_templates.exists %}
 {% if devicetype.consoleport_templates.exists or devicetype.powerport_templates.exists %}
     <div class="row">
     <div class="row">
         <div class="col-md-6">
         <div class="col-md-6">
-            {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:consoleporttemplate_add' edit_url='dcim:consoleporttemplate_bulk_edit' delete_url='dcim:consoleporttemplate_bulk_delete' %}
         </div>
         </div>
         <div class="col-md-6">
         <div class="col-md-6">
-             {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %}
+             {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:powerporttemplate_add' edit_url='dcim:powerporttemplate_bulk_edit' delete_url='dcim:powerporttemplate_bulk_delete' %}
         </div>
         </div>
     </div>
     </div>
 {% endif %}
 {% endif %}
 {% if devicetype.is_parent_device or devicebay_table.rows %}
 {% if devicetype.is_parent_device or devicebay_table.rows %}
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <div class="col-md-12">
-            {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicebaytemplate_add' edit_url=None delete_url='dcim:devicebaytemplate_bulk_delete' %}
         </div>
         </div>
     </div>
     </div>
 {% endif %}
 {% endif %}
 {% if devicetype.consoleserverport_templates.exists %}
 {% if devicetype.consoleserverport_templates.exists %}
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <div class="col-md-12">
-            {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:consoleserverporttemplate_add' edit_url='dcim:consoleserverporttemplate_bulk_edit' delete_url='dcim:consoleserverporttemplate_bulk_delete' %}
         </div>
         </div>
     </div>
     </div>
 {% endif %}
 {% endif %}
 {% if devicetype.poweroutlet_templates.exists %}
 {% if devicetype.poweroutlet_templates.exists %}
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <div class="col-md-12">
-            {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:poweroutlettemplate_add' edit_url='dcim:poweroutlettemplate_bulk_edit' delete_url='dcim:poweroutlettemplate_bulk_delete' %}
         </div>
         </div>
     </div>
     </div>
 {% endif %}
 {% endif %}
 {% if devicetype.interface_templates.exists %}
 {% if devicetype.interface_templates.exists %}
     <div class="row">
     <div class="row">
         <div class="col-md-12">
         <div class="col-md-12">
-            {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' delete_url='dcim:devicetype_delete_interface' %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:interfacetemplate_add' edit_url='dcim:interfacetemplate_bulk_edit' delete_url='dcim:interfacetemplate_bulk_delete' %}
         </div>
         </div>
     </div>
     </div>
 {% endif %}
 {% endif %}
 {% if devicetype.frontport_templates.exists or devicetype.rearport_templates.exists %}
 {% if devicetype.frontport_templates.exists or devicetype.rearport_templates.exists %}
     <div class="row">
     <div class="row">
         <div class="col-md-6">
         <div class="col-md-6">
-            {% include 'dcim/inc/devicetype_component_table.html' with table=front_port_table title='Front Ports' add_url='dcim:devicetype_add_frontport' delete_url='dcim:devicetype_delete_frontport' %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=front_port_table title='Front Ports' add_url='dcim:frontporttemplate_add' edit_url='dcim:frontporttemplate_bulk_edit' delete_url='dcim:frontporttemplate_bulk_delete' %}
         </div>
         </div>
         <div class="col-md-6">
         <div class="col-md-6">
-            {% include 'dcim/inc/devicetype_component_table.html' with table=rear_port_table title='Rear Ports' add_url='dcim:devicetype_add_rearport' delete_url='dcim:devicetype_delete_rearport' %}
+            {% include 'dcim/inc/devicetype_component_table.html' with table=rear_port_table title='Rear Ports' add_url='dcim:rearporttemplate_add' edit_url='dcim:rearporttemplate_bulk_edit' delete_url='dcim:rearporttemplate_bulk_delete' %}
         </div>
         </div>
     </div>
     </div>
 {% endif %}
 {% endif %}

+ 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 %}

+ 3 - 3
netbox/templates/dcim/inc/devicetype_component_table.html

@@ -9,18 +9,18 @@
             <div class="panel-footer noprint">
             <div class="panel-footer noprint">
                 {% if table.rows %}
                 {% if table.rows %}
                     {% if edit_url %}
                     {% if edit_url %}
-                        <button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
+                        <button type="submit" name="_edit" formaction="{% url edit_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
                     {% if delete_url %}
                     {% if delete_url %}
-                        <button type="submit" name="_delete" formaction="{% url delete_url pk=devicetype.pk %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
+                        <button type="submit" name="_delete" formaction="{% url delete_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
                 {% endif %}
                 {% endif %}
                 <div class="pull-right">
                 <div class="pull-right">
-                    <a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
+                    <a href="{% url add_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-primary btn-xs">
                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
                         Add {{ title }}
                         Add {{ title }}
                     </a>
                     </a>

+ 0 - 6
netbox/templates/dcim/inc/rack_elevation.html

@@ -1,11 +1,5 @@
 {% load helpers %}
 {% load helpers %}
 
 
-<ul class="rack_legend">
-    {% for u in rack.units %}
-        <li>{{ u }}</li>
-    {% endfor %}
-</ul>
-
 <div class="rack_frame">
 <div class="rack_frame">
 
 
   <object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"></object>
   <object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"></object>

+ 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 %}

+ 17 - 17
netbox/templates/home.html

@@ -127,23 +127,6 @@
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Secrets</strong>
-            </div>
-            <div class="list-group">
-                <div class="list-group-item">
-                    {% if perms.secrets.view_secret %}
-                        <span class="badge pull-right">{{ stats.secret_count }}</span>
-                        <h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
-                    {% else %}
-                        <span class="badge pull-right"><i class="fa fa-lock"></i></span>
-                        <h4 class="list-group-item-heading">Secrets</h4>
-                    {% endif %}
-                    <p class="list-group-item-text text-muted">Cryptographically secured secret data</p>
-                </div>
-            </div>
-        </div>
     </div>
     </div>
     <div class="col-sm-6 col-md-4">
     <div class="col-sm-6 col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -259,6 +242,23 @@
         </div>
         </div>
     </div>
     </div>
     <div class="col-sm-6 col-md-4">
     <div class="col-sm-6 col-md-4">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Secrets</strong>
+            </div>
+            <div class="list-group">
+                <div class="list-group-item">
+                    {% if perms.secrets.view_secret %}
+                        <span class="badge pull-right">{{ stats.secret_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
+                    {% else %}
+                        <span class="badge pull-right"><i class="fa fa-lock"></i></span>
+                        <h4 class="list-group-item-heading">Secrets</h4>
+                    {% endif %}
+                    <p class="list-group-item-text text-muted">Cryptographically secured secret data</p>
+                </div>
+            </div>
+        </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Reports</strong>
                 <strong>Reports</strong>

+ 4 - 4
netbox/templates/inc/custom_fields_panel.html

@@ -9,13 +9,13 @@
                     <tr>
                     <tr>
                         <td>{{ field }}</td>
                         <td>{{ field }}</td>
                         <td>
                         <td>
-                            {% if field.type == 300 and value == True %}
+                            {% if field.type == 'boolean' and value == True %}
                                 <i class="glyphicon glyphicon-ok text-success" title="True"></i>
                                 <i class="glyphicon glyphicon-ok text-success" title="True"></i>
-                            {% elif field.type == 300 and value == False %}
+                            {% elif field.type == 'boolean' and value == False %}
                                 <i class="glyphicon glyphicon-remove text-danger" title="False"></i>
                                 <i class="glyphicon glyphicon-remove text-danger" title="False"></i>
-                            {% elif field.type == 500 and value %}
+                            {% elif field.type == 'url' and value %}
                                 <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
                                 <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
-                            {% elif field.type == 200 or value %}
+                            {% elif field.type == 'integer' or value %}
                                 {{ value }}
                                 {{ value }}
                             {% elif field.required %}
                             {% elif field.required %}
                                 <span class="text-warning">Not defined</span>
                                 <span class="text-warning">Not defined</span>

+ 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 %}
@@ -478,6 +478,11 @@
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <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 %}>
+                            {% 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 %}
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
                             <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
                         </li>
                         </li>
                         <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
                         <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>

+ 2 - 1
netbox/templates/secrets/secret_edit.html

@@ -1,6 +1,7 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load static %}
 {% load static %}
 {% load form_helpers %}
 {% load form_helpers %}
+{% load secret_helpers %}
 
 
 {% block content %}
 {% block content %}
 <form action="." method="post" class="form form-horizontal">
 <form action="." method="post" class="form form-horizontal">
@@ -34,7 +35,7 @@
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading"><strong>Secret Data</strong></div>
                 <div class="panel-heading"><strong>Secret Data</strong></div>
                 <div class="panel-body">
                 <div class="panel-body">
-                    {% if secret.pk %}
+                    {% if secret.pk and secret|decryptable_by:request.user %}
                         <div class="form-group">
                         <div class="form-group">
                             <label class="col-md-3 control-label required">Current Plaintext</label>
                             <label class="col-md-3 control-label required">Current Plaintext</label>
                             <div class="col-md-7">
                             <div class="col-md-7">

+ 3 - 3
netbox/templates/virtualization/virtualmachine.html

@@ -288,18 +288,18 @@
                         <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                         <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                         </button>
                         </button>
-                        <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=virtualmachine.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
+                        <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
                     {% if interfaces and perms.dcim.delete_interface %}
                     {% if interfaces and perms.dcim.delete_interface %}
-                        <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=virtualmachine.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
+                        <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
                     {% if perms.dcim.add_interface %}
                     {% if perms.dcim.add_interface %}
                         <div class="pull-right">
                         <div class="pull-right">
-                            <a href="{% url 'virtualization:interface_add' pk=virtualmachine.pk %}" class="btn btn-primary btn-xs">
+                            <a href="{% url 'virtualization:interface_add' %}?virtual_machine={{ virtualmachine.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                             </a>
                             </a>
                         </div>
                         </div>

+ 1 - 1
netbox/templates/virtualization/virtualmachine_component_add.html

@@ -5,7 +5,7 @@
 {% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
 {% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
 
 
 {% block content %}
 {% block content %}
-<form action="." method="post" class="form form-horizontal">
+<form action="" method="post" class="form form-horizontal">
     {% csrf_token %}
     {% csrf_token %}
     <div class="row">
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
         <div class="col-md-6 col-md-offset-3">

+ 3 - 3
netbox/tenancy/api/urls.py

@@ -15,11 +15,11 @@ router = routers.DefaultRouter()
 router.APIRootView = TenancyRootView
 router.APIRootView = TenancyRootView
 
 
 # Field choices
 # Field choices
-router.register(r'_choices', views.TenancyFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.TenancyFieldChoicesViewSet, basename='field-choice')
 
 
 # Tenants
 # Tenants
-router.register(r'tenant-groups', views.TenantGroupViewSet)
-router.register(r'tenants', views.TenantViewSet)
+router.register('tenant-groups', views.TenantGroupViewSet)
+router.register('tenants', views.TenantViewSet)
 
 
 app_name = 'tenancy-api'
 app_name = 'tenancy-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 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",

+ 3 - 8
netbox/tenancy/tests/test_views.py

@@ -1,15 +1,10 @@
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.testing import StandardTestCases
+from utilities.testing import ViewTestCases
 
 
 
 
-class TenantGroupTestCase(StandardTestCases.Views):
+class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = TenantGroup
     model = TenantGroup
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -32,7 +27,7 @@ class TenantGroupTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class TenantTestCase(StandardTestCases.Views):
+class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Tenant
     model = Tenant
 
 
     @classmethod
     @classmethod

+ 15 - 15
netbox/tenancy/urls.py

@@ -8,22 +8,22 @@ app_name = 'tenancy'
 urlpatterns = [
 urlpatterns = [
 
 
     # Tenant groups
     # Tenant groups
-    path(r'tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
-    path(r'tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'),
-    path(r'tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
-    path(r'tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
-    path(r'tenant-groups/<slug:slug>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
-    path(r'tenant-groups/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
+    path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
+    path('tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'),
+    path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
+    path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
+    path('tenant-groups/<slug:slug>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
+    path('tenant-groups/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),
 
 
     # Tenants
     # Tenants
-    path(r'tenants/', views.TenantListView.as_view(), name='tenant_list'),
-    path(r'tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'),
-    path(r'tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'),
-    path(r'tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
-    path(r'tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
-    path(r'tenants/<slug:slug>/', views.TenantView.as_view(), name='tenant'),
-    path(r'tenants/<slug:slug>/edit/', views.TenantEditView.as_view(), name='tenant_edit'),
-    path(r'tenants/<slug:slug>/delete/', views.TenantDeleteView.as_view(), name='tenant_delete'),
-    path(r'tenants/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
+    path('tenants/', views.TenantListView.as_view(), name='tenant_list'),
+    path('tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'),
+    path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'),
+    path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
+    path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
+    path('tenants/<slug:slug>/', views.TenantView.as_view(), name='tenant'),
+    path('tenants/<slug:slug>/edit/', views.TenantEditView.as_view(), name='tenant_edit'),
+    path('tenants/<slug:slug>/delete/', views.TenantDeleteView.as_view(), name='tenant_delete'),
+    path('tenants/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}),
 
 
 ]
 ]

+ 9 - 9
netbox/users/urls.py

@@ -5,14 +5,14 @@ from . import views
 app_name = 'user'
 app_name = 'user'
 urlpatterns = [
 urlpatterns = [
 
 
-    path(r'profile/', views.ProfileView.as_view(), name='profile'),
-    path(r'password/', views.ChangePasswordView.as_view(), name='change_password'),
-    path(r'api-tokens/', views.TokenListView.as_view(), name='token_list'),
-    path(r'api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
-    path(r'api-tokens/<int:pk>/edit/', views.TokenEditView.as_view(), name='token_edit'),
-    path(r'api-tokens/<int:pk>/delete/', views.TokenDeleteView.as_view(), name='token_delete'),
-    path(r'user-key/', views.UserKeyView.as_view(), name='userkey'),
-    path(r'user-key/edit/', views.UserKeyEditView.as_view(), name='userkey_edit'),
-    path(r'session-key/delete/', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'),
+    path('profile/', views.ProfileView.as_view(), name='profile'),
+    path('password/', views.ChangePasswordView.as_view(), name='change_password'),
+    path('api-tokens/', views.TokenListView.as_view(), name='token_list'),
+    path('api-tokens/add/', views.TokenEditView.as_view(), name='token_add'),
+    path('api-tokens/<int:pk>/edit/', views.TokenEditView.as_view(), name='token_edit'),
+    path('api-tokens/<int:pk>/delete/', views.TokenDeleteView.as_view(), name='token_delete'),
+    path('user-key/', views.UserKeyView.as_view(), name='userkey'),
+    path('user-key/edit/', views.UserKeyEditView.as_view(), name='userkey_edit'),
+    path('session-key/delete/', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'),
 
 
 ]
 ]

+ 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,
+        )

+ 35 - 125
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,
@@ -451,12 +448,14 @@ class ExpandableNameField(forms.CharField):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         if not self.help_text:
         if not self.help_text:
-            self.help_text = 'Alphanumeric ranges are supported for bulk creation.<br />' \
-                             'Mixed cases and types within a single range are not supported.<br />' \
-                             'Examples:<ul><li><code>ge-0/0/[0-23,25,30]</code></li>' \
-                             '<li><code>e[0-3][a-d,f]</code></li>' \
-                             '<li><code>[xe,ge]-0/0/0</code></li>' \
-                             '<li><code>e[0-3,a-d,f]</code></li></ul>'
+            self.help_text = """
+                Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
+                are not supported. Examples:
+                <ul>
+                    <li><code>[ge,xe]-0/0/[0-9]</code></li>
+                    <li><code>e[0-3][a-d,f]</code></li>
+                </ul>
+                """
 
 
     def to_python(self, value):
     def to_python(self, value):
         if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
         if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
@@ -523,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.
@@ -579,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):
+class DynamicModelChoiceMixin:
+    field_modifier = ''
 
 
-    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)
+    def get_bound_field(self, form, field_name):
+        bound_field = BoundField(form, self, field_name)
 
 
+        # 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()
 
 
-class FilterChoiceFieldMixin(object):
-    iterator = FilterChoiceIterator
-
-    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)
-
-    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):
@@ -673,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.
@@ -727,26 +650,13 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm):
     confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
     confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
 
 
 
 
-class ComponentForm(BootstrapMixin, forms.Form):
-    """
-    Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices.
-    """
-    def __init__(self, parent, *args, **kwargs):
-        self.parent = parent
-        super().__init__(*args, **kwargs)
-
-    def get_iterative_data(self, iteration):
-        return {}
-
-
 class BulkEditForm(forms.Form):
 class BulkEditForm(forms.Form):
     """
     """
     Base form for editing multiple objects in bulk
     Base form for editing multiple objects in bulk
     """
     """
-    def __init__(self, model, parent_obj=None, *args, **kwargs):
+    def __init__(self, model, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self.model = model
         self.model = model
-        self.parent_obj = parent_obj
         self.nullable_fields = []
         self.nullable_fields = []
 
 
         # Copy any nullable fields defined in Meta
         # Copy any nullable fields defined in Meta

+ 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 six 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/utilities/templatetags/helpers.py

@@ -82,7 +82,7 @@ def render_yaml(value):
     """
     """
     Render a dictionary as formatted YAML.
     Render a dictionary as formatted YAML.
     """
     """
-    return yaml.dump(dict(value))
+    return yaml.dump(json.loads(json.dumps(value)))
 
 
 
 
 @register.filter()
 @register.filter()

+ 220 - 86
netbox/utilities/testing/testcases.py

@@ -1,11 +1,12 @@
 from django.contrib.auth.models import Permission, User
 from django.contrib.auth.models import Permission, User
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
+from django.forms.models import model_to_dict
 from django.test import Client, TestCase as _TestCase, override_settings
 from django.test import Client, TestCase as _TestCase, override_settings
 from django.urls import reverse, NoReverseMatch
 from django.urls import reverse, NoReverseMatch
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
 
 
 from users.models import Token
 from users.models import Token
-from .utils import disable_warnings, model_to_dict, post_data
+from .utils import disable_warnings, post_data
 
 
 
 
 class TestCase(_TestCase):
 class TestCase(_TestCase):
@@ -57,6 +58,77 @@ class TestCase(_TestCase):
         ))
         ))
 
 
 
 
+class ModelViewTestCase(TestCase):
+    """
+    Base TestCase for model views. Subclass to test individual views.
+    """
+    model = None
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if self.model is None:
+            raise Exception("Test case requires model to be defined")
+
+    def _get_base_url(self):
+        """
+        Return the base format for a URL for the test's model. Override this to test for a model which belongs
+        to a different app (e.g. testing Interfaces within the virtualization app).
+        """
+        return '{}:{}_{{}}'.format(
+            self.model._meta.app_label,
+            self.model._meta.model_name
+        )
+
+    def _get_url(self, action, instance=None):
+        """
+        Return the URL name for a specific action. An instance must be specified for
+        get/edit/delete views.
+        """
+        url_format = self._get_base_url()
+
+        if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'):
+            return reverse(url_format.format(action))
+
+        elif action in ('get', 'edit', 'delete'):
+            if instance is None:
+                raise Exception("Resolving {} URL requires specifying an instance".format(action))
+            # Attempt to resolve using slug first
+            if hasattr(self.model, 'slug'):
+                try:
+                    return reverse(url_format.format(action), kwargs={'slug': instance.slug})
+                except NoReverseMatch:
+                    pass
+            return reverse(url_format.format(action), kwargs={'pk': instance.pk})
+
+        else:
+            raise Exception("Invalid action for URL resolution: {}".format(action))
+
+    def assertInstanceEqual(self, instance, data):
+        """
+        Compare a model instance to a dictionary, checking that its attribute values match those specified
+        in the dictionary.
+        """
+        model_dict = model_to_dict(instance, fields=data.keys())
+
+        for key in list(model_dict.keys()):
+
+            # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
+            if key == 'tags':
+                model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']]))
+
+            # Convert ManyToManyField to list of instance PKs
+            elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'):
+                model_dict[key] = [obj.pk for obj in model_dict[key]]
+
+        # Omit any dictionary keys which are not instance attributes
+        relevant_data = {
+            k: v for k, v in data.items() if hasattr(instance, k)
+        }
+
+        self.assertDictEqual(model_dict, relevant_data)
+
+
 class APITestCase(TestCase):
 class APITestCase(TestCase):
     client_class = APIClient
     client_class = APIClient
 
 
@@ -69,87 +141,14 @@ class APITestCase(TestCase):
         self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
         self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
 
 
 
 
-class StandardTestCases:
+class ViewTestCases:
     """
     """
     We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them.
     We keep any TestCases with test_* methods inside a class to prevent unittest from trying to run them.
     """
     """
-
-    class Views(TestCase):
+    class GetObjectViewTestCase(ModelViewTestCase):
         """
         """
-        Stock TestCase suitable for testing all standard View functions:
-            - List objects
-            - View single object
-            - Create new object
-            - Modify existing object
-            - Delete existing object
-            - Import multiple new objects
+        Retrieve a single instance.
         """
         """
-        model = None
-
-        # Data to be sent when creating/editing individual objects
-        form_data = {}
-
-        # CSV lines used for bulk import of new objects
-        csv_data = ()
-
-        # Form data to be used when editing multiple objects at once
-        bulk_edit_data = {}
-
-        maxDiff = None
-
-        def __init__(self, *args, **kwargs):
-
-            super().__init__(*args, **kwargs)
-
-            if self.model is None:
-                raise Exception("Test case requires model to be defined")
-
-        def _get_url(self, action, instance=None):
-            """
-            Return the URL name for a specific action. An instance must be specified for
-            get/edit/delete views.
-            """
-            url_format = '{}:{}_{{}}'.format(
-                self.model._meta.app_label,
-                self.model._meta.model_name
-            )
-
-            if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'):
-                return reverse(url_format.format(action))
-
-            elif action in ('get', 'edit', 'delete'):
-                if instance is None:
-                    raise Exception("Resolving {} URL requires specifying an instance".format(action))
-                # Attempt to resolve using slug first
-                if hasattr(self.model, 'slug'):
-                    try:
-                        return reverse(url_format.format(action), kwargs={'slug': instance.slug})
-                    except NoReverseMatch:
-                        pass
-                return reverse(url_format.format(action), kwargs={'pk': instance.pk})
-
-            else:
-                raise Exception("Invalid action for URL resolution: {}".format(action))
-
-        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
-        def test_list_objects(self):
-            # Attempt to make the request without required permissions
-            with disable_warnings('django.request'):
-                self.assertHttpStatus(self.client.get(self._get_url('list')), 403)
-
-            # Assign the required permission and submit again
-            self.add_permissions(
-                '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
-            )
-            response = self.client.get(self._get_url('list'))
-            self.assertHttpStatus(response, 200)
-
-            # Built-in CSV export
-            if hasattr(self.model, 'csv_headers'):
-                response = self.client.get('{}?export'.format(self._get_url('list')))
-                self.assertHttpStatus(response, 200)
-                self.assertEqual(response.get('Content-Type'), 'text/csv')
-
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_get_object(self):
         def test_get_object(self):
             instance = self.model.objects.first()
             instance = self.model.objects.first()
@@ -165,6 +164,12 @@ class StandardTestCases:
             response = self.client.get(instance.get_absolute_url())
             response = self.client.get(instance.get_absolute_url())
             self.assertHttpStatus(response, 200)
             self.assertHttpStatus(response, 200)
 
 
+    class CreateObjectViewTestCase(ModelViewTestCase):
+        """
+        Create a single new instance.
+        """
+        form_data = {}
+
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_create_object(self):
         def test_create_object(self):
             initial_count = self.model.objects.count()
             initial_count = self.model.objects.count()
@@ -187,7 +192,13 @@ class StandardTestCases:
 
 
             self.assertEqual(initial_count + 1, self.model.objects.count())
             self.assertEqual(initial_count + 1, self.model.objects.count())
             instance = self.model.objects.order_by('-pk').first()
             instance = self.model.objects.order_by('-pk').first()
-            self.assertDictEqual(model_to_dict(instance), self.form_data)
+            self.assertInstanceEqual(instance, self.form_data)
+
+    class EditObjectViewTestCase(ModelViewTestCase):
+        """
+        Edit a single existing instance.
+        """
+        form_data = {}
 
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_edit_object(self):
         def test_edit_object(self):
@@ -211,8 +222,12 @@ class StandardTestCases:
             self.assertHttpStatus(response, 302)
             self.assertHttpStatus(response, 302)
 
 
             instance = self.model.objects.get(pk=instance.pk)
             instance = self.model.objects.get(pk=instance.pk)
-            self.assertDictEqual(model_to_dict(instance), self.form_data)
+            self.assertInstanceEqual(instance, self.form_data)
 
 
+    class DeleteObjectViewTestCase(ModelViewTestCase):
+        """
+        Delete a single instance.
+        """
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_delete_object(self):
         def test_delete_object(self):
             instance = self.model.objects.first()
             instance = self.model.objects.first()
@@ -237,6 +252,66 @@ class StandardTestCases:
             with self.assertRaises(ObjectDoesNotExist):
             with self.assertRaises(ObjectDoesNotExist):
                 self.model.objects.get(pk=instance.pk)
                 self.model.objects.get(pk=instance.pk)
 
 
+    class ListObjectsViewTestCase(ModelViewTestCase):
+        """
+        Retrieve multiple instances.
+        """
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_list_objects(self):
+            # Attempt to make the request without required permissions
+            with disable_warnings('django.request'):
+                self.assertHttpStatus(self.client.get(self._get_url('list')), 403)
+
+            # Assign the required permission and submit again
+            self.add_permissions(
+                '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+            )
+            response = self.client.get(self._get_url('list'))
+            self.assertHttpStatus(response, 200)
+
+            # Built-in CSV export
+            if hasattr(self.model, 'csv_headers'):
+                response = self.client.get('{}?export'.format(self._get_url('list')))
+                self.assertHttpStatus(response, 200)
+                self.assertEqual(response.get('Content-Type'), 'text/csv')
+
+    class BulkCreateObjectsViewTestCase(ModelViewTestCase):
+        """
+        Create multiple instances using a single form. Expects the creation of three new instances by default.
+        """
+        bulk_create_count = 3
+        bulk_create_data = {}
+
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_bulk_create_objects(self):
+            initial_count = self.model.objects.count()
+            request = {
+                'path': self._get_url('add'),
+                'data': post_data(self.bulk_create_data),
+                'follow': False,  # Do not follow 302 redirects
+            }
+
+            # Attempt to make the request without required permissions
+            with disable_warnings('django.request'):
+                self.assertHttpStatus(self.client.post(**request), 403)
+
+            # Assign the required permission and submit again
+            self.add_permissions(
+                '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+            )
+            response = self.client.post(**request)
+            self.assertHttpStatus(response, 302)
+
+            self.assertEqual(initial_count + self.bulk_create_count, self.model.objects.count())
+            for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]:
+                self.assertInstanceEqual(instance, self.bulk_create_data)
+
+    class ImportObjectsViewTestCase(ModelViewTestCase):
+        """
+        Create multiple instances from imported data.
+        """
+        csv_data = ()
+
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_import_objects(self):
         def test_import_objects(self):
             initial_count = self.model.objects.count()
             initial_count = self.model.objects.count()
@@ -261,9 +336,16 @@ class StandardTestCases:
 
 
             self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
             self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
 
 
+    class BulkEditObjectsViewTestCase(ModelViewTestCase):
+        """
+        Edit multiple instances.
+        """
+        bulk_edit_data = {}
+
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_bulk_edit_objects(self):
         def test_bulk_edit_objects(self):
-            pk_list = self.model.objects.values_list('pk', flat=True)
+            # Bulk edit the first three objects only
+            pk_list = self.model.objects.values_list('pk', flat=True)[:3]
 
 
             request = {
             request = {
                 'path': self._get_url('bulk_edit'),
                 'path': self._get_url('bulk_edit'),
@@ -288,14 +370,13 @@ class StandardTestCases:
             response = self.client.post(**request)
             response = self.client.post(**request)
             self.assertHttpStatus(response, 302)
             self.assertHttpStatus(response, 302)
 
 
-            bulk_edit_fields = self.bulk_edit_data.keys()
             for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
             for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
-                self.assertDictEqual(
-                    model_to_dict(instance, fields=bulk_edit_fields),
-                    self.bulk_edit_data,
-                    msg="Instance {} failed to validate after bulk edit: {}".format(i, instance)
-                )
+                self.assertInstanceEqual(instance, self.bulk_edit_data)
 
 
+    class BulkDeleteObjectsViewTestCase(ModelViewTestCase):
+        """
+        Delete multiple instances.
+        """
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_bulk_delete_objects(self):
         def test_bulk_delete_objects(self):
             pk_list = self.model.objects.values_list('pk', flat=True)
             pk_list = self.model.objects.values_list('pk', flat=True)
@@ -323,3 +404,56 @@ class StandardTestCases:
 
 
             # Check that all objects were deleted
             # Check that all objects were deleted
             self.assertEqual(self.model.objects.count(), 0)
             self.assertEqual(self.model.objects.count(), 0)
+
+    class PrimaryObjectViewTestCase(
+        GetObjectViewTestCase,
+        CreateObjectViewTestCase,
+        EditObjectViewTestCase,
+        DeleteObjectViewTestCase,
+        ListObjectsViewTestCase,
+        ImportObjectsViewTestCase,
+        BulkEditObjectsViewTestCase,
+        BulkDeleteObjectsViewTestCase,
+    ):
+        """
+        TestCase suitable for testing all standard View functions for primary objects
+        """
+        maxDiff = None
+
+    class OrganizationalObjectViewTestCase(
+        CreateObjectViewTestCase,
+        EditObjectViewTestCase,
+        ListObjectsViewTestCase,
+        ImportObjectsViewTestCase,
+        BulkDeleteObjectsViewTestCase,
+    ):
+        """
+        TestCase suitable for all organizational objects
+        """
+        maxDiff = None
+
+    class DeviceComponentTemplateViewTestCase(
+        EditObjectViewTestCase,
+        DeleteObjectViewTestCase,
+        BulkCreateObjectsViewTestCase,
+        BulkEditObjectsViewTestCase,
+        BulkDeleteObjectsViewTestCase,
+    ):
+        """
+        TestCase suitable for testing device component template models (ConsolePortTemplates, InterfaceTemplates, etc.)
+        """
+        maxDiff = None
+
+    class DeviceComponentViewTestCase(
+        EditObjectViewTestCase,
+        DeleteObjectViewTestCase,
+        ListObjectsViewTestCase,
+        BulkCreateObjectsViewTestCase,
+        ImportObjectsViewTestCase,
+        BulkEditObjectsViewTestCase,
+        BulkDeleteObjectsViewTestCase,
+    ):
+        """
+        TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.)
+        """
+        maxDiff = None

+ 3 - 30
netbox/utilities/testing/utils.py

@@ -2,35 +2,6 @@ import logging
 from contextlib import contextmanager
 from contextlib import contextmanager
 
 
 from django.contrib.auth.models import Permission, User
 from django.contrib.auth.models import Permission, User
-from django.forms.models import model_to_dict as _model_to_dict
-
-
-def model_to_dict(instance, fields=None, exclude=None):
-    """
-    Customized wrapper for Django's built-in model_to_dict(). Does the following:
-      - Excludes the instance ID field
-      - Exclude any fields prepended with an underscore
-      - Convert any assigned tags to a comma-separated string
-    """
-    _exclude = ['id']
-    if exclude is not None:
-        _exclude += exclude
-
-    model_dict = _model_to_dict(instance, fields=fields, exclude=_exclude)
-
-    for key in list(model_dict.keys()):
-        if key.startswith('_'):
-            del model_dict[key]
-
-        # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
-        elif key == 'tags':
-            model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']]))
-
-        # Convert ManyToManyField to list of instance PKs
-        elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'):
-            model_dict[key] = [obj.pk for obj in model_dict[key]]
-
-    return model_dict
 
 
 
 
 def post_data(data):
 def post_data(data):
@@ -50,11 +21,13 @@ def post_data(data):
     return ret
     return ret
 
 
 
 
-def create_test_user(username='testuser', permissions=list()):
+def create_test_user(username='testuser', permissions=None):
     """
     """
     Create a User with the given permissions.
     Create a User with the given permissions.
     """
     """
     user = User.objects.create_user(username=username)
     user = User.objects.create_user(username=username)
+    if permissions is None:
+        permissions = ()
     for perm_name in permissions:
     for perm_name in permissions:
         app, codename = perm_name.split('.')
         app, codename = perm_name.split('.')
         perm = Permission.objects.get(content_type__app_label=app, codename=codename)
         perm = Permission.objects.get(content_type__app_label=app, codename=codename)

+ 43 - 0
netbox/utilities/tests/test_ordering.py

@@ -0,0 +1,43 @@
+from django.test import TestCase
+
+from utilities.ordering import naturalize, naturalize_interface
+
+
+class NaturalizationTestCase(TestCase):
+    """
+    Validate the operation of the functions which generate values suitable for natural ordering.
+    """
+    def test_naturalize(self):
+
+        data = (
+            # Original, naturalized
+            ('abc', 'abc'),
+            ('123', '00000123'),
+            ('abc123', 'abc00000123'),
+            ('123abc', '00000123abc'),
+            ('123abc456', '00000123abc00000456'),
+            ('abc123def', 'abc00000123def'),
+            ('abc123def456', 'abc00000123def00000456'),
+        )
+
+        for origin, naturalized in data:
+            self.assertEqual(naturalize(origin), naturalized)
+
+    def test_naturalize_interface(self):
+
+        data = (
+            # Original, naturalized
+            ('Gi', '9999999999999999Gi000000000000000000'),
+            ('Gi1', '9999999999999999Gi000001000000000000'),
+            ('Gi1/2', '0001999999999999Gi000002000000000000'),
+            ('Gi1/2/3', '0001000299999999Gi000003000000000000'),
+            ('Gi1/2/3/4', '0001000200039999Gi000004000000000000'),
+            ('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'),
+            ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'),
+            ('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
+            ('Gi1:2', '9999999999999999Gi000001000002000000'),
+            ('Gi1:2.3', '9999999999999999Gi000001000002000003'),
+        )
+
+        for origin, naturalized in data:
+            self.assertEqual(naturalize_interface(origin), naturalized)

+ 13 - 0
netbox/utilities/utils.py

@@ -4,6 +4,7 @@ from collections import OrderedDict
 
 
 from django.core.serializers import serialize
 from django.core.serializers import serialize
 from django.db.models import Count, OuterRef, Subquery
 from django.db.models import Count, OuterRef, Subquery
+from django.http import QueryDict
 from jinja2 import Environment
 from jinja2 import Environment
 
 
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
@@ -209,3 +210,15 @@ def prepare_cloned_fields(instance):
     )
     )
 
 
     return param_string
     return param_string
+
+
+def querydict_to_dict(querydict):
+    """
+    Convert a django.http.QueryDict object to a regular Python dictionary, preserving lists of multiple values.
+    (QueryDict.dict() will return only the last value in a list for each key.)
+    """
+    assert isinstance(querydict, QueryDict)
+    return {
+        key: querydict.get(key) if len(value) == 1 and key != 'pk' else querydict.getlist(key)
+        for key, value in querydict.lists()
+    }

+ 30 - 50
netbox/utilities/views.py

@@ -25,7 +25,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
 from extras.querysets import CustomFieldQueryset
 from extras.querysets import CustomFieldQueryset
 from utilities.exceptions import AbortTransaction
 from utilities.exceptions import AbortTransaction
 from utilities.forms import BootstrapMixin, CSVDataField
 from utilities.forms import BootstrapMixin, CSVDataField
-from utilities.utils import csv_format, prepare_cloned_fields
+from utilities.utils import csv_format, prepare_cloned_fields, querydict_to_dict
 from .error_handlers import handle_protectederror
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm, ImportForm
 from .forms import ConfirmationForm, ImportForm
 from .paginator import EnhancedPaginator
 from .paginator import EnhancedPaginator
@@ -604,14 +604,12 @@ class BulkEditView(GetReturnURLMixin, View):
     Edit objects in bulk.
     Edit objects in bulk.
 
 
     queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
     queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
-    parent_model: The model of the parent object (if any)
     filter: FilterSet to apply when deleting by QuerySet
     filter: FilterSet to apply when deleting by QuerySet
     table: The table used to display devices being edited
     table: The table used to display devices being edited
     form: The form class used to edit objects in bulk
     form: The form class used to edit objects in bulk
     template_name: The name of the template
     template_name: The name of the template
     """
     """
     queryset = None
     queryset = None
-    parent_model = None
     filterset = None
     filterset = None
     table = None
     table = None
     form = None
     form = None
@@ -624,20 +622,15 @@ class BulkEditView(GetReturnURLMixin, View):
 
 
         model = self.queryset.model
         model = self.queryset.model
 
 
-        # Attempt to derive parent object if a parent class has been given
-        if self.parent_model:
-            parent_obj = get_object_or_404(self.parent_model, **kwargs)
-        else:
-            parent_obj = None
+        # Create a mutable copy of the POST data
+        post_data = request.POST.copy()
 
 
-        # Are we editing *all* objects in the queryset or just a selected subset?
-        if request.POST.get('_all') and self.filterset is not None:
-            pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
-        else:
-            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
+        # If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
+        if post_data.get('_all') and self.filterset is not None:
+            post_data['pk'] = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
 
 
         if '_apply' in request.POST:
         if '_apply' in request.POST:
-            form = self.form(model, parent_obj, request.POST)
+            form = self.form(model, request.POST, initial=request.GET)
             if form.is_valid():
             if form.is_valid():
 
 
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
@@ -651,7 +644,7 @@ class BulkEditView(GetReturnURLMixin, View):
                     with transaction.atomic():
                     with transaction.atomic():
 
 
                         updated_count = 0
                         updated_count = 0
-                        for obj in model.objects.filter(pk__in=pk_list):
+                        for obj in model.objects.filter(pk__in=form.cleaned_data['pk']):
 
 
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             for name in standard_fields:
                             for name in standard_fields:
@@ -719,12 +712,16 @@ class BulkEditView(GetReturnURLMixin, View):
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
 
 
         else:
         else:
-            initial_data = request.POST.copy()
-            initial_data['pk'] = pk_list
-            form = self.form(model, parent_obj, initial=initial_data)
+            # Pass the PK list as initial data to avoid binding the form
+            initial_data = querydict_to_dict(post_data)
+
+            # Append any normal initial data (passed as GET parameters)
+            initial_data.update(request.GET)
+
+            form = self.form(model, initial=initial_data)
 
 
         # Retrieve objects being edited
         # Retrieve objects being edited
-        table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
+        table = self.table(self.queryset.filter(pk__in=post_data.getlist('pk')), orderable=False)
         if not table.rows:
         if not table.rows:
             messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
             messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
             return redirect(self.get_return_url(request))
             return redirect(self.get_return_url(request))
@@ -742,14 +739,12 @@ class BulkDeleteView(GetReturnURLMixin, View):
     Delete objects in bulk.
     Delete objects in bulk.
 
 
     queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
     queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
-    parent_model: The model of the parent object (if any)
     filter: FilterSet to apply when deleting by QuerySet
     filter: FilterSet to apply when deleting by QuerySet
     table: The table used to display devices being deleted
     table: The table used to display devices being deleted
     form: The form class used to delete objects in bulk
     form: The form class used to delete objects in bulk
     template_name: The name of the template
     template_name: The name of the template
     """
     """
     queryset = None
     queryset = None
-    parent_model = None
     filterset = None
     filterset = None
     table = None
     table = None
     form = None
     form = None
@@ -762,12 +757,6 @@ class BulkDeleteView(GetReturnURLMixin, View):
 
 
         model = self.queryset.model
         model = self.queryset.model
 
 
-        # Attempt to derive parent object if a parent class has been given
-        if self.parent_model:
-            parent_obj = get_object_or_404(self.parent_model, **kwargs)
-        else:
-            parent_obj = None
-
         # Are we deleting *all* objects in the queryset or just a selected subset?
         # Are we deleting *all* objects in the queryset or just a selected subset?
         if request.POST.get('_all'):
         if request.POST.get('_all'):
             if self.filterset is not None:
             if self.filterset is not None:
@@ -809,7 +798,6 @@ class BulkDeleteView(GetReturnURLMixin, View):
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
-            'parent_obj': parent_obj,
             'obj_type_plural': model._meta.verbose_name_plural,
             'obj_type_plural': model._meta.verbose_name_plural,
             'table': table,
             'table': table,
             'return_url': self.get_return_url(request),
             'return_url': self.get_return_url(request),
@@ -832,47 +820,40 @@ class BulkDeleteView(GetReturnURLMixin, View):
 # Device/VirtualMachine components
 # Device/VirtualMachine components
 #
 #
 
 
-class ComponentCreateView(View):
+# TODO: Replace with BulkCreateView
+class ComponentCreateView(GetReturnURLMixin, View):
     """
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     """
     """
-    parent_model = None
-    parent_field = None
     model = None
     model = None
     form = None
     form = None
     model_form = None
     model_form = None
     template_name = None
     template_name = None
 
 
-    def get(self, request, pk):
+    def get(self, request):
 
 
-        parent = get_object_or_404(self.parent_model, pk=pk)
-        data = deepcopy(request.GET)
-        data[self.parent_field] = parent.pk
-        form = self.form(parent, initial=data)
+        form = self.form(initial=request.GET)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
-            'parent': parent,
             'component_type': self.model._meta.verbose_name,
             'component_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'return_url': parent.get_absolute_url(),
+            'return_url': self.get_return_url(request),
         })
         })
 
 
-    def post(self, request, pk):
-
-        parent = get_object_or_404(self.parent_model, pk=pk)
+    def post(self, request):
 
 
-        form = self.form(parent, request.POST)
+        form = self.form(request.POST, initial=request.GET)
         if form.is_valid():
         if form.is_valid():
 
 
             new_components = []
             new_components = []
             data = deepcopy(request.POST)
             data = deepcopy(request.POST)
-            data[self.parent_field] = parent.pk
 
 
             for i, name in enumerate(form.cleaned_data['name_pattern']):
             for i, name in enumerate(form.cleaned_data['name_pattern']):
 
 
                 # Initialize the individual component form
                 # Initialize the individual component form
                 data['name'] = name
                 data['name'] = name
-                data.update(form.get_iterative_data(i))
+                if hasattr(form, 'get_iterative_data'):
+                    data.update(form.get_iterative_data(i))
                 component_form = self.model_form(data)
                 component_form = self.model_form(data)
 
 
                 if component_form.is_valid():
                 if component_form.is_valid():
@@ -891,19 +872,18 @@ class ComponentCreateView(View):
                 for component_form in new_components:
                 for component_form in new_components:
                     component_form.save()
                     component_form.save()
 
 
-                messages.success(request, "Added {} {} to {}.".format(
-                    len(new_components), self.model._meta.verbose_name_plural, parent
+                messages.success(request, "Added {} {}".format(
+                    len(new_components), self.model._meta.verbose_name_plural
                 ))
                 ))
                 if '_addanother' in request.POST:
                 if '_addanother' in request.POST:
-                    return redirect(request.path)
+                    return redirect(request.get_full_path())
                 else:
                 else:
-                    return redirect(parent.get_absolute_url())
+                    return redirect(self.get_return_url(request))
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
-            'parent': parent,
             'component_type': self.model._meta.verbose_name,
             'component_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'return_url': parent.get_absolute_url(),
+            'return_url': self.get_return_url(request),
         })
         })
 
 
 
 

+ 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(),

+ 6 - 6
netbox/virtualization/api/urls.py

@@ -15,16 +15,16 @@ router = routers.DefaultRouter()
 router.APIRootView = VirtualizationRootView
 router.APIRootView = VirtualizationRootView
 
 
 # Field choices
 # Field choices
-router.register(r'_choices', views.VirtualizationFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.VirtualizationFieldChoicesViewSet, basename='field-choice')
 
 
 # Clusters
 # Clusters
-router.register(r'cluster-types', views.ClusterTypeViewSet)
-router.register(r'cluster-groups', views.ClusterGroupViewSet)
-router.register(r'clusters', views.ClusterViewSet)
+router.register('cluster-types', views.ClusterTypeViewSet)
+router.register('cluster-groups', views.ClusterGroupViewSet)
+router.register('clusters', views.ClusterViewSet)
 
 
 # VirtualMachines
 # VirtualMachines
-router.register(r'virtual-machines', views.VirtualMachineViewSet)
-router.register(r'interfaces', views.InterfaceViewSet)
+router.register('virtual-machines', views.VirtualMachineViewSet)
+router.register('interfaces', views.InterfaceViewSet)
 
 
 app_name = 'virtualization-api'
 app_name = 'virtualization-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 8 - 2
netbox/virtualization/choices.py

@@ -8,14 +8,20 @@ from utilities.choices import ChoiceSet
 
 
 class VirtualMachineStatusChoices(ChoiceSet):
 class VirtualMachineStatusChoices(ChoiceSet):
 
 
-    STATUS_ACTIVE = 'active'
     STATUS_OFFLINE = 'offline'
     STATUS_OFFLINE = 'offline'
+    STATUS_ACTIVE = 'active'
+    STATUS_PLANNED = 'planned'
     STATUS_STAGED = 'staged'
     STATUS_STAGED = 'staged'
+    STATUS_FAILED = 'failed'
+    STATUS_DECOMMISSIONING = 'decommissioning'
 
 
     CHOICES = (
     CHOICES = (
-        (STATUS_ACTIVE, 'Active'),
         (STATUS_OFFLINE, 'Offline'),
         (STATUS_OFFLINE, 'Offline'),
+        (STATUS_ACTIVE, 'Active'),
+        (STATUS_PLANNED, 'Planned'),
         (STATUS_STAGED, 'Staged'),
         (STATUS_STAGED, 'Staged'),
+        (STATUS_FAILED, 'Failed'),
+        (STATUS_DECOMMISSIONING, 'Decommissioning'),
     )
     )
 
 
     LEGACY_MAP = {
     LEGACY_MAP = {

+ 130 - 114
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, ComponentForm,
-    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(
@@ -738,7 +742,11 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             self.cleaned_data['tagged_vlans'] = []
             self.cleaned_data['tagged_vlans'] = []
 
 
 
 
-class InterfaceCreateForm(ComponentForm):
+class InterfaceCreateForm(BootstrapMixin, forms.Form):
+    virtual_machine = forms.ModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -748,7 +756,8 @@ class InterfaceCreateForm(ComponentForm):
         widget=forms.HiddenInput()
         widget=forms.HiddenInput()
     )
     )
     enabled = forms.BooleanField(
     enabled = forms.BooleanField(
-        required=False
+        required=False,
+        initial=True
     )
     )
     mtu = forms.IntegerField(
     mtu = forms.IntegerField(
         required=False,
         required=False,
@@ -769,7 +778,7 @@ class InterfaceCreateForm(ComponentForm):
         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(
@@ -778,7 +787,7 @@ class InterfaceCreateForm(ComponentForm):
             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(
@@ -792,14 +801,13 @@ class InterfaceCreateForm(ComponentForm):
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-
-        # Set interfaces enabled by default
-        kwargs['initial'] = kwargs.get('initial', {}).copy()
-        kwargs['initial'].update({'enabled': True})
-
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
+        virtual_machine = VirtualMachine.objects.get(
+            pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+        )
+
+        # Limit VLAN choices to those in: global vlans, global groups, the current site's group, the current site
         vlan_choices = []
         vlan_choices = []
         global_vlans = VLAN.objects.filter(site=None, group=None)
         global_vlans = VLAN.objects.filter(site=None, group=None)
         vlan_choices.append(
         vlan_choices.append(
@@ -811,7 +819,7 @@ class InterfaceCreateForm(ComponentForm):
                 (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
                 (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
             )
             )
 
 
-        site = getattr(self.parent.cluster, 'site', None)
+        site = getattr(virtual_machine.cluster, 'site', None)
         if site is not None:
         if site is not None:
 
 
             # Add non-grouped site VLANs
             # Add non-grouped site VLANs
@@ -835,6 +843,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    virtual_machine = forms.ModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        widget=forms.HiddenInput()
+    )
     enabled = forms.NullBooleanField(
     enabled = forms.NullBooleanField(
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect()
         widget=BulkEditNullBooleanSelect()
@@ -854,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(
@@ -863,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(
@@ -881,35 +893,39 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
-        vlan_choices = []
-        global_vlans = VLAN.objects.filter(site=None, group=None)
-        vlan_choices.append(
-            ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
-        )
-        for group in VLANGroup.objects.filter(site=None):
-            global_group_vlans = VLAN.objects.filter(group=group)
+        # Limit available VLANs based on the parent VirtualMachine
+        if 'virtual_machine' in self.initial:
+            parent_obj = VirtualMachine.objects.filter(pk=self.initial['virtual_machine']).first()
+
+            # Limit VLAN choices to global VLANs, VLANs in global groups, the current site's group, the current site
+            vlan_choices = []
+            global_vlans = VLAN.objects.filter(site=None, group=None)
             vlan_choices.append(
             vlan_choices.append(
-                (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+                ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
             )
             )
-        if self.parent_obj.cluster is not None:
-            site = getattr(self.parent_obj.cluster, 'site', None)
-            if site is not None:
-
-                # Add non-grouped site VLANs
-                site_vlans = VLAN.objects.filter(site=site, group=None)
-                vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
-
-                # Add grouped site VLANs
-                for group in VLANGroup.objects.filter(site=site):
-                    site_group_vlans = VLAN.objects.filter(group=group)
-                    vlan_choices.append((
-                        '{} / {}'.format(group.site.name, group.name),
-                        [(vlan.pk, vlan) for vlan in site_group_vlans]
-                    ))
-
-        self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
-        self.fields['tagged_vlans'].choices = vlan_choices
+            for group in VLANGroup.objects.filter(site=None):
+                global_group_vlans = VLAN.objects.filter(group=group)
+                vlan_choices.append(
+                    (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+                )
+            if parent_obj.cluster is not None:
+                site = getattr(parent_obj.cluster, 'site', None)
+                if site is not None:
+
+                    # Add non-grouped site VLANs
+                    site_vlans = VLAN.objects.filter(site=site, group=None)
+                    vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
+
+                    # Add grouped site VLANs
+                    for group in VLANGroup.objects.filter(site=site):
+                        site_group_vlans = VLAN.objects.filter(group=group)
+                        vlan_choices.append((
+                            '{} / {}'.format(group.site.name, group.name),
+                            [(vlan.pk, vlan) for vlan in site_group_vlans]
+                        ))
+
+            self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
+            self.fields['tagged_vlans'].choices = vlan_choices
 
 
 
 
 #
 #

+ 6 - 3
netbox/virtualization/models.py

@@ -267,9 +267,12 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     ]
     ]
 
 
     STATUS_CLASS_MAP = {
     STATUS_CLASS_MAP = {
-        'active': 'success',
-        'offline': 'warning',
-        'staged': 'primary',
+        VirtualMachineStatusChoices.STATUS_OFFLINE: 'warning',
+        VirtualMachineStatusChoices.STATUS_ACTIVE: 'success',
+        VirtualMachineStatusChoices.STATUS_PLANNED: 'info',
+        VirtualMachineStatusChoices.STATUS_STAGED: 'primary',
+        VirtualMachineStatusChoices.STATUS_FAILED: 'danger',
+        VirtualMachineStatusChoices.STATUS_DECOMMISSIONING: 'warning',
     }
     }
 
 
     class Meta:
     class Meta:

+ 99 - 16
netbox/virtualization/tests/test_views.py

@@ -1,17 +1,16 @@
-from dcim.models import DeviceRole, Platform, Site
-from utilities.testing import StandardTestCases
+from netaddr import EUI
+
+from dcim.choices import InterfaceModeChoices
+from dcim.models import DeviceRole, Interface, Platform, Site
+from ipam.models import VLAN
+from utilities.testing import ViewTestCases
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
 
 
-class ClusterGroupTestCase(StandardTestCases.Views):
+class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = ClusterGroup
     model = ClusterGroup
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -34,14 +33,9 @@ class ClusterGroupTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class ClusterTypeTestCase(StandardTestCases.Views):
+class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = ClusterType
     model = ClusterType
 
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_delete_object = None
-    test_bulk_edit_objects = None
-
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -64,7 +58,7 @@ class ClusterTypeTestCase(StandardTestCases.Views):
         )
         )
 
 
 
 
-class ClusterTestCase(StandardTestCases.Views):
+class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Cluster
     model = Cluster
 
 
     @classmethod
     @classmethod
@@ -120,7 +114,7 @@ class ClusterTestCase(StandardTestCases.Views):
         }
         }
 
 
 
 
-class VirtualMachineTestCase(StandardTestCases.Views):
+class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = VirtualMachine
     model = VirtualMachine
 
 
     @classmethod
     @classmethod
@@ -187,3 +181,92 @@ class VirtualMachineTestCase(StandardTestCases.Views):
             'disk': 8000,
             'disk': 8000,
             'comments': 'New comments',
             'comments': 'New comments',
         }
         }
+
+
+class InterfaceTestCase(
+    ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.DeviceComponentViewTestCase,
+):
+    model = Interface
+
+    # Disable inapplicable tests
+    test_list_objects = None
+    test_import_objects = None
+
+    def _get_base_url(self):
+        # Interface belongs to the DCIM app, so we have to override the base URL
+        return 'virtualization:interface_{}'
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
+        virtualmachines = (
+            VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole),
+            VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole),
+        )
+        VirtualMachine.objects.bulk_create(virtualmachines)
+
+        Interface.objects.bulk_create([
+            Interface(virtual_machine=virtualmachines[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(virtual_machine=virtualmachines[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(virtual_machine=virtualmachines[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        ])
+
+        vlans = (
+            VLAN(vid=1, name='VLAN1', site=site),
+            VLAN(vid=101, name='VLAN101', site=site),
+            VLAN(vid=102, name='VLAN102', site=site),
+            VLAN(vid=103, name='VLAN103', site=site),
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        cls.form_data = {
+            'virtual_machine': virtualmachines[1].pk,
+            'name': 'Interface X',
+            'type': InterfaceTypeChoices.TYPE_VIRTUAL,
+            'enabled': False,
+            'mgmt_only': False,
+            'mac_address': EUI('01-02-03-04-05-06'),
+            'mtu': 2000,
+            'description': 'New description',
+            'mode': InterfaceModeChoices.MODE_TAGGED,
+            'untagged_vlan': vlans[0].pk,
+            'tagged_vlans': [v.pk for v in vlans[1:4]],
+            'tags': 'Alpha,Bravo,Charlie',
+        }
+
+        cls.bulk_create_data = {
+            'virtual_machine': virtualmachines[1].pk,
+            'name_pattern': 'Interface [4-6]',
+            'type': InterfaceTypeChoices.TYPE_VIRTUAL,
+            'enabled': False,
+            'mgmt_only': False,
+            'mac_address': EUI('01-02-03-04-05-06'),
+            'mtu': 2000,
+            'description': 'New description',
+            'mode': InterfaceModeChoices.MODE_TAGGED,
+            'untagged_vlan': vlans[0].pk,
+            'tagged_vlans': [v.pk for v in vlans[1:4]],
+            'tags': 'Alpha,Bravo,Charlie',
+        }
+
+        cls.bulk_edit_data = {
+            'virtual_machine': virtualmachines[1].pk,
+            'enabled': False,
+            'mtu': 2000,
+            'description': 'New description',
+            'mode': InterfaceModeChoices.MODE_TAGGED,
+            # 'untagged_vlan': vlans[0].pk,
+            # 'tagged_vlans': [v.pk for v in vlans[1:4]],
+        }
+
+        cls.csv_data = (
+            "device,name,type",
+            "Device 1,Interface 4,1000BASE-T (1GE)",
+            "Device 1,Interface 5,1000BASE-T (1GE)",
+            "Device 1,Interface 6,1000BASE-T (1GE)",
+        )

+ 40 - 40
netbox/virtualization/urls.py

@@ -9,53 +9,53 @@ app_name = 'virtualization'
 urlpatterns = [
 urlpatterns = [
 
 
     # Cluster types
     # Cluster types
-    path(r'cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'),
-    path(r'cluster-types/add/', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
-    path(r'cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
-    path(r'cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
-    path(r'cluster-types/<slug:slug>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
-    path(r'cluster-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
+    path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'),
+    path('cluster-types/add/', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
+    path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
+    path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
+    path('cluster-types/<slug:slug>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
+    path('cluster-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
 
 
     # Cluster groups
     # Cluster groups
-    path(r'cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
-    path(r'cluster-groups/add/', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
-    path(r'cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
-    path(r'cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
-    path(r'cluster-groups/<slug:slug>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
-    path(r'cluster-groups/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
+    path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
+    path('cluster-groups/add/', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
+    path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
+    path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
+    path('cluster-groups/<slug:slug>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
+    path('cluster-groups/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),
 
 
     # Clusters
     # Clusters
-    path(r'clusters/', views.ClusterListView.as_view(), name='cluster_list'),
-    path(r'clusters/add/', views.ClusterCreateView.as_view(), name='cluster_add'),
-    path(r'clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'),
-    path(r'clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
-    path(r'clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
-    path(r'clusters/<int:pk>/', views.ClusterView.as_view(), name='cluster'),
-    path(r'clusters/<int:pk>/edit/', views.ClusterEditView.as_view(), name='cluster_edit'),
-    path(r'clusters/<int:pk>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'),
-    path(r'clusters/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
-    path(r'clusters/<int:pk>/devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
-    path(r'clusters/<int:pk>/devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
+    path('clusters/', views.ClusterListView.as_view(), name='cluster_list'),
+    path('clusters/add/', views.ClusterCreateView.as_view(), name='cluster_add'),
+    path('clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'),
+    path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
+    path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
+    path('clusters/<int:pk>/', views.ClusterView.as_view(), name='cluster'),
+    path('clusters/<int:pk>/edit/', views.ClusterEditView.as_view(), name='cluster_edit'),
+    path('clusters/<int:pk>/delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'),
+    path('clusters/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}),
+    path('clusters/<int:pk>/devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
+    path('clusters/<int:pk>/devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
 
 
     # Virtual machines
     # Virtual machines
-    path(r'virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
-    path(r'virtual-machines/add/', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'),
-    path(r'virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
-    path(r'virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
-    path(r'virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
-    path(r'virtual-machines/<int:pk>/', views.VirtualMachineView.as_view(), name='virtualmachine'),
-    path(r'virtual-machines/<int:pk>/edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
-    path(r'virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
-    path(r'virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
-    path(r'virtual-machines/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
-    path(r'virtual-machines/<int:virtualmachine>/services/assign/', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
+    path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
+    path('virtual-machines/add/', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'),
+    path('virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
+    path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
+    path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
+    path('virtual-machines/<int:pk>/', views.VirtualMachineView.as_view(), name='virtualmachine'),
+    path('virtual-machines/<int:pk>/edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
+    path('virtual-machines/<int:pk>/delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
+    path('virtual-machines/<int:pk>/config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
+    path('virtual-machines/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
+    path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
 
 
     # VM interfaces
     # VM interfaces
-    path(r'virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),
-    path(r'virtual-machines/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
-    path(r'virtual-machines/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
-    path(r'virtual-machines/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    path(r'vm-interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
-    path(r'vm-interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
+    path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
+    path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
+    path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
+    path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
+    path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),
 
 
 ]
 ]

Некоторые файлы не были показаны из-за большого количества измененных файлов