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

Merge branch 'develop' into 3840-limit-vlan-choices

Saria Hajjar 6 лет назад
Родитель
Сommit
f0c83e168e
100 измененных файлов с 3678 добавлено и 8040 удалено
  1. 5 3
      .github/ISSUE_TEMPLATE/bug_report.md
  2. 10 0
      .github/ISSUE_TEMPLATE/documentation_change.md
  3. 5 3
      .github/ISSUE_TEMPLATE/feature_request.md
  4. 4 5
      .github/ISSUE_TEMPLATE/housekeeping.md
  5. 9 1
      docs/configuration/optional-settings.md
  6. 3 2
      docs/installation/3-http-daemon.md
  7. 2 2
      docs/installation/4-ldap.md
  8. 28 71
      docs/installation/migrating-to-systemd.md
  9. 51 1
      docs/release-notes/version-2.7.md
  10. 15 3
      netbox/circuits/api/serializers.py
  11. 5 5
      netbox/circuits/api/urls.py
  12. 3 1
      netbox/circuits/api/views.py
  13. 13 8
      netbox/circuits/forms.py
  14. 77 96
      netbox/circuits/tests/test_views.py
  15. 30 30
      netbox/circuits/urls.py
  16. 37 37
      netbox/dcim/api/urls.py
  17. 0 5790
      netbox/dcim/fixtures/dcim.json
  18. 441 67
      netbox/dcim/forms.py
  19. 1 55
      netbox/dcim/managers.py
  20. 1 1
      netbox/dcim/migrations/0079_3569_rack_fields.py
  21. 27 0
      netbox/dcim/migrations/0092_fix_rack_outer_unit.py
  22. 147 0
      netbox/dcim/migrations/0093_device_component_ordering.py
  23. 138 0
      netbox/dcim/migrations/0094_device_component_template_ordering.py
  24. 70 0
      netbox/dcim/migrations/0095_primary_model_ordering.py
  25. 53 0
      netbox/dcim/migrations/0096_interface_ordering.py
  26. 30 24
      netbox/dcim/models/__init__.py
  27. 62 37
      netbox/dcim/models/device_component_templates.py
  28. 73 42
      netbox/dcim/models/device_components.py
  29. 20 4
      netbox/dcim/tables.py
  30. 76 50
      netbox/dcim/tests/test_forms.py
  31. 653 231
      netbox/dcim/tests/test_views.py
  32. 261 248
      netbox/dcim/urls.py
  33. 85 68
      netbox/dcim/views.py
  34. 15 1
      netbox/extras/api/serializers.py
  35. 10 10
      netbox/extras/api/urls.py
  36. 17 0
      netbox/extras/filters.py
  37. 0 35
      netbox/extras/fixtures/extras.json
  38. 75 103
      netbox/extras/forms.py
  39. 24 0
      netbox/extras/migrations/0037_configcontexts_clusters.py
  40. 81 0
      netbox/extras/models.py
  41. 6 0
      netbox/extras/querysets.py
  42. 5 4
      netbox/extras/scripts.py
  43. 113 2
      netbox/extras/tests/test_customfields.py
  44. 30 0
      netbox/extras/tests/test_filters.py
  45. 58 42
      netbox/extras/tests/test_views.py
  46. 23 23
      netbox/extras/urls.py
  47. 3 3
      netbox/extras/views.py
  48. 3 2
      netbox/ipam/api/serializers.py
  49. 10 10
      netbox/ipam/api/urls.py
  50. 0 329
      netbox/ipam/fixtures/ipam.json
  51. 34 15
      netbox/ipam/forms.py
  52. 1 1
      netbox/ipam/migrations/0029_3569_ipaddress_fields.py
  53. 21 0
      netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py
  54. 3 0
      netbox/ipam/tests/test_api.py
  55. 226 276
      netbox/ipam/tests/test_views.py
  56. 76 76
      netbox/ipam/urls.py
  57. 3 1
      netbox/netbox/settings.py
  58. 3 3
      netbox/netbox/tests/test_views.py
  59. 36 34
      netbox/netbox/urls.py
  60. 1 1
      netbox/netbox/views.py
  61. 11 0
      netbox/project-static/js/configcontext.js
  62. 9 4
      netbox/project-static/js/interface_toggles.js
  63. 5 5
      netbox/secrets/api/urls.py
  64. 7 4
      netbox/secrets/forms.py
  65. 68 77
      netbox/secrets/tests/test_views.py
  66. 14 14
      netbox/secrets/urls.py
  67. 0 1
      netbox/templates/circuits/circuit_list.html
  68. 0 1
      netbox/templates/circuits/provider_list.html
  69. 1 1
      netbox/templates/dcim/cable_trace.html
  70. 2 5
      netbox/templates/dcim/consoleport_list.html
  71. 17 0
      netbox/templates/dcim/consoleserverport_list.html
  72. 49 29
      netbox/templates/dcim/device.html
  73. 2 8
      netbox/templates/dcim/device_component_add.html
  74. 1 1
      netbox/templates/dcim/device_inventory.html
  75. 0 1
      netbox/templates/dcim/device_list.html
  76. 17 0
      netbox/templates/dcim/devicebay_list.html
  77. 16 16
      netbox/templates/dcim/devicetype.html
  78. 0 1
      netbox/templates/dcim/devicetype_list.html
  79. 17 0
      netbox/templates/dcim/frontport_list.html
  80. 1 1
      netbox/templates/dcim/inc/cable_toggle_buttons.html
  81. 1 1
      netbox/templates/dcim/inc/consoleport.html
  82. 1 1
      netbox/templates/dcim/inc/consoleserverport.html
  83. 3 3
      netbox/templates/dcim/inc/devicetype_component_table.html
  84. 1 1
      netbox/templates/dcim/inc/frontport.html
  85. 1 1
      netbox/templates/dcim/inc/interface.html
  86. 1 1
      netbox/templates/dcim/inc/poweroutlet.html
  87. 1 1
      netbox/templates/dcim/inc/powerport.html
  88. 1 1
      netbox/templates/dcim/inc/rearport.html
  89. 17 0
      netbox/templates/dcim/interface_list.html
  90. 0 1
      netbox/templates/dcim/powerfeed_list.html
  91. 17 0
      netbox/templates/dcim/poweroutlet_list.html
  92. 17 0
      netbox/templates/dcim/powerport_list.html
  93. 0 1
      netbox/templates/dcim/rack_list.html
  94. 17 0
      netbox/templates/dcim/rearport_list.html
  95. 0 1
      netbox/templates/dcim/site_list.html
  96. 0 1
      netbox/templates/dcim/virtualchassis_list.html
  97. 35 1
      netbox/templates/extras/configcontext.html
  98. 2 0
      netbox/templates/extras/configcontext_edit.html
  99. 8 0
      netbox/templates/extras/inc/configcontext_data.html
  100. 6 0
      netbox/templates/extras/inc/configcontext_format.html

+ 5 - 3
.github/ISSUE_TEMPLATE/bug_report.md

@@ -5,7 +5,9 @@ about: Report a reproducible bug in the current release of NetBox
 ---
 
 <!--
-    NOTE: This form is only for reproducible bugs. If you need assistance with
+    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
+
+    This form is only for reproducible bugs. If you need assistance with
     NetBox installation, or if you have a general question, DO NOT open an
     issue. Instead, post to our mailing list:
 
@@ -16,8 +18,8 @@ about: Report a reproducible bug in the current release of NetBox
     before submitting a bug report.
 -->
 ### Environment
-* Python version:  <!-- Example: 3.5.4 -->
-* NetBox version:  <!-- Example: 2.5.2 -->
+* Python version:  <!-- Example: 3.6.9 -->
+* NetBox version:  <!-- Example: 2.7.3 -->
 
 <!--
     Describe in detail the exact steps that someone else can take to reproduce

+ 10 - 0
.github/ISSUE_TEMPLATE/documentation_change.md

@@ -5,6 +5,8 @@ about: Suggest an addition or modification to the NetBox documentation
 ---
 
 <!--
+    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
+
     Please indicate the nature of the change by placing an X in one of the
     boxes below.
 -->
@@ -14,5 +16,13 @@ about: Suggest an addition or modification to the NetBox documentation
 [ ] Deprecation
 [ ] Cleanup (formatting, typos, etc.)
 
+### Area
+[ ] Installation instructions
+[ ] Configuration parameters
+[ ] Functionality/features
+[ ] REST API
+[ ] Administration/development
+[ ] Other
+
 <!-- Describe the proposed change(s). -->
 ### Proposed Changes

+ 5 - 3
.github/ISSUE_TEMPLATE/feature_request.md

@@ -5,7 +5,9 @@ about: Propose a new NetBox feature or enhancement
 ---
 
 <!--
-    NOTE: This form is only for proposing specific new features or enhancements.
+    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
+
+    This form is only for proposing specific new features or enhancements.
     If you have a general idea or question, please post to our mailing list
     instead of opening an issue:
 
@@ -19,8 +21,8 @@ about: Propose a new NetBox feature or enhancement
     before submitting a bug report.
 -->
 ### Environment
-* Python version:  <!-- Example: 3.5.4 -->
-* NetBox version:  <!-- Example: 2.3.6 -->
+* Python version:  <!-- Example: 3.6.9 -->
+* NetBox version:  <!-- Example: 2.7.3 -->
 
 <!--
     Describe in detail the new functionality you are proposing. Include any

+ 4 - 5
.github/ISSUE_TEMPLATE/housekeeping.md

@@ -1,14 +1,13 @@
 ---
 name: 🏡 Housekeeping
-about: A change pertaining to the codebase itself
+about: A change pertaining to the codebase itself (developers only)
 
 ---
 
 <!--
-    NOTE: This type of issue should be opened only by those reasonably familiar
-    with NetBox's code base and interested in contributing to its development.
-
-    Describe the proposed change(s) in detail.
+    NOTE: This template is for use by maintainers only. Please do not submit
+    an issue using this template unless you have been specifically asked to
+    do so.
 -->
 ### Proposed Changes
 

+ 9 - 1
docs/configuration/optional-settings.md

@@ -90,6 +90,14 @@ This setting enables debugging. This should be done only during development or t
 
 ---
 
+## DEVELOPER
+
+Default: False
+
+This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base.
+
+---
+
 ## EMAIL
 
 In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting:
@@ -127,7 +135,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
 
 ---
 
-# ENFORCE_GLOBAL_UNIQUE
+## ENFORCE_GLOBAL_UNIQUE
 
 Default: False
 

+ 3 - 2
docs/installation/3-http-daemon.md

@@ -29,7 +29,7 @@ server {
 
     location / {
         proxy_pass http://127.0.0.1:8001;
-        proxy_set_header X-Forwarded-Host $server_name;
+        proxy_set_header X-Forwarded-Host $http_host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-Proto $scheme;
     }
@@ -107,9 +107,10 @@ Install gunicorn:
 # pip3 install gunicorn
 ```
 
-Copy `contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.
+Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.
 
 ```no-highlight
+# cd /opt/netbox
 # cp contrib/gunicorn.py /opt/netbox/gunicorn.py
 ```
 

+ 2 - 2
docs/installation/4-ldap.md

@@ -110,8 +110,8 @@ AUTH_LDAP_USER_FLAGS_BY_GROUP = {
 AUTH_LDAP_FIND_GROUP_PERMS = True
 
 # Cache groups for one hour to reduce LDAP traffic
-AUTH_LDAP_CACHE_GROUPS = True
-AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
+AUTH_LDAP_CACHE_TIMEOUT = 3600
+
 ```
 
 * `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in.

+ 28 - 71
docs/installation/migrating-to-systemd.md

@@ -12,89 +12,46 @@ Migration is not required, as supervisord will still continue to function.
 
 ### systemd configuration:
 
-Copy or link contrib/netbox.service and contrib/netbox-rq.service to /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service
+We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory:
 
 ```no-highlight
-# cp contrib/netbox.service /etc/systemd/system/netbox.service
-# cp contrib/netbox-rq.service /etc/systemd/system/netbox-rq.service
+# cp contrib/*.service /etc/systemd/system/
 ```
 
-Edit /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service. Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`).  If using CentOS/RHEL.  Change the username from `www-data` to `nginx` or `apache`:
+!!! note
+    These service files assume that gunicorn is installed at `/usr/local/bin/gunicorn`. If the output of `which gunicorn` indicates a different path, you'll need to correct the `ExecStart` path in both files.
 
-```no-highlight
-/usr/local/bin/gunicorn --pid ${PidPath} --pythonpath ${WorkingDirectory}/netbox --config ${ConfigPath} netbox.wsgi
-```
-
-```no-highlight
-User=www-data
-Group=www-data
-```
-
-Copy contrib/netbox.env to /etc/sysconfig/netbox.env
-
-```no-highlight
-# cp contrib/netbox.env /etc/sysconfig/netbox.env
-```
-
-Edit /etc/sysconfig/netbox.env and change the settings as required.  Update the `WorkingDirectory` variable if needed.
-
-```no-highlight
-# Name is the Process Name
-#
-Name = 'Netbox'
-
-# ConfigPath is the path to the gunicorn config file.
-#
-ConfigPath=/opt/netbox/gunicorn.conf
-
-# WorkingDirectory is the Working Directory for Netbox.
-#
-WorkingDirectory=/opt/netbox/
-
-# PidPath is the path to the pid for the netbox WSGI
-#
-PidPath=/var/run/netbox.pid
-```
+!!! note
+    You may need to modify the user that the systemd service runs as.  Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group.  The username could be "nobody", "nginx", "apache", "www-data" or any number of other usernames.
 
-Copy contrib/gunicorn.conf to gunicorn.conf
+Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
 
 ```no-highlight
-# cp contrib/gunicorn.conf to gunicorn.conf
+# systemctl daemon-reload
+# systemctl start netbox.service
+# systemctl start netbox-rq.service
+# systemctl enable netbox.service
+# systemctl enable netbox-rq.service
 ```
 
-Edit gunicorn.conf and change the settings as required.
+You can use the command `systemctl status netbox` to verify that the WSGI service is running:
 
 ```
-# Bind is the ip and port that the Netbox WSGI should bind to
-#
-bind='127.0.0.1:8001'
-
-# Workers is the number of workers that GUnicorn should spawn.
-# Workers should be: cores * 2 + 1.  So if you have 8 cores, it would be 17.
-#
-workers=3
-
-# Threads
-#     The number of threads for handling requests
-#
-threads=3
-
-# Timeout is the timeout between gunicorn receiving a request and returning a response (or failing with a 500 error)
-#
-timeout=120
-
-# ErrorLog
-#     ErrorLog is the logfile for the ErrorLog
-#
-errorlog='/opt/netbox/netbox.log'
+# systemctl status netbox.service
+● netbox.service - NetBox WSGI Service
+   Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
+   Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
+     Docs: https://netbox.readthedocs.io/en/stable/
+ Main PID: 11993 (gunicorn)
+    Tasks: 6 (limit: 2362)
+   CGroup: /system.slice/netbox.service
+           ├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
+           ├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
+           ├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
+...
 ```
 
-Finally, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
+At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.
 
-```no-highlight
-# systemctl daemon-reload
-# systemctl start netbox.service
-# systemctl start netbox-rq.service
-# systemctl enable netbox.service
-# systemctl enable netbox-rq.service
-```
+!!! info
+    Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.

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

@@ -1,9 +1,51 @@
-# v2.7.3 (FUTURE)
+# v2.7.5 (FUTURE)
+
+## Enhancements
+
+* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components
+* [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views
+* [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components
+* [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views
+
+## Bug Fixes
+
+* [#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
+
+# v2.7.4 (2020-02-04)
+
+## Enhancements
+
+* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV
+* [#2921](https://github.com/netbox-community/netbox/issues/2921) - Replace tags filter with Select2 widget
+* [#3313](https://github.com/netbox-community/netbox/issues/3313) - Toggle config context display between JSON and YAML
+* [#3886](https://github.com/netbox-community/netbox/issues/3886) - Enable assigning config contexts by cluster and cluster group
+* [#4051](https://github.com/netbox-community/netbox/issues/4051) - Disable the `makemigrations` management command
+
+## Bug Fixes
+
+* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised)
+* [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts
+* [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer
+* [#4052](https://github.com/netbox-community/netbox/issues/4052) - Fix error when bulk importing interfaces to virtual machines
+* [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569)
+* [#4067](https://github.com/netbox-community/netbox/issues/4067) - Correct permission checked when creating a rack (vs. editing)
+* [#4071](https://github.com/netbox-community/netbox/issues/4071) - Enforce "view tag" permission on individual tag view
+* [#4079](https://github.com/netbox-community/netbox/issues/4079) - Fix assignment of power panel when bulk editing power feeds
+* [#4084](https://github.com/netbox-community/netbox/issues/4084) - Fix exception when creating an interface with tagged VLANs
+
+---
+
+# v2.7.3 (2020-01-28)
 
 ## Enhancements
 
 * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable
+* [#3338](https://github.com/netbox-community/netbox/issues/3338) - Include circuit terminations in API representation of circuits
 * [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts
+* [#3978](https://github.com/netbox-community/netbox/issues/3978) - Add VRF filtering to search NAT IP
 * [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps
 
 ## Bug Fixes
@@ -14,6 +56,14 @@
 * [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks
 * [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank
 * [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings
+* [#4017](https://github.com/netbox-community/netbox/issues/4017) - Remove redundant tenant field from cluster form
+* [#4019](https://github.com/netbox-community/netbox/issues/4019) - Restore border around background devices in rack elevations
+* [#4022](https://github.com/netbox-community/netbox/issues/4022) - Fix display of assigned IPs when filtering device interfaces
+* [#4025](https://github.com/netbox-community/netbox/issues/4025) - Correct display of cable status (various places)
+* [#4027](https://github.com/netbox-community/netbox/issues/4027) - Repair schema migration for #3569 to convert IP addresses with DHCP status
+* [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs
+* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when setting interfaces to tagged mode in bulk
+* [#4033](https://github.com/netbox-community/netbox/issues/4033) - Restore missing comments field label of various bulk edit forms
 
 ---
 

+ 15 - 3
netbox/circuits/api/serializers.py

@@ -3,11 +3,11 @@ from taggit_serializer.serializers import TaggitSerializer, TagListSerializerFie
 
 from circuits.choices import CircuitStatusChoices
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
-from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
+from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
 from dcim.api.serializers import ConnectedEndpointSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
-from utilities.api import ChoiceField, ValidatedModelSerializer
+from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
 from .nested_serializers import *
 
 
@@ -39,18 +39,30 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
         fields = ['id', 'name', 'slug', 'description', 'circuit_count']
 
 
+class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
+    site = NestedSiteSerializer()
+    connected_endpoint = NestedInterfaceSerializer()
+
+    class Meta:
+        model = CircuitTermination
+        fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
+
+
 class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
     provider = NestedProviderSerializer()
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
+    termination_a = CircuitCircuitTerminationSerializer(read_only=True)
+    termination_z = CircuitCircuitTerminationSerializer(read_only=True)
     tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Circuit
         fields = [
             'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
 
 

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

@@ -15,15 +15,15 @@ router = routers.DefaultRouter()
 router.APIRootView = CircuitsRootView
 
 # Field choices
-router.register(r'_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice')
 
 # Providers
-router.register(r'providers', views.ProviderViewSet)
+router.register('providers', views.ProviderViewSet)
 
 # 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'
 urlpatterns = router.urls

+ 3 - 1
netbox/circuits/api/views.py

@@ -62,7 +62,9 @@ class CircuitTypeViewSet(ModelViewSet):
 #
 
 class CircuitViewSet(CustomFieldModelViewSet):
-    queryset = Circuit.objects.prefetch_related('type', 'tenant', 'provider').prefetch_related('tags')
+    queryset = Circuit.objects.prefetch_related(
+        'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device'
+    ).prefetch_related('tags')
     serializer_class = serializers.CircuitSerializer
     filterset_class = filters.CircuitFilterSet
 

+ 13 - 8
netbox/circuits/forms.py

@@ -2,12 +2,14 @@ from django import forms
 from taggit.forms import TagField
 
 from dcim.models import Region, Site
-from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
+)
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
-    DatePicker, FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
+    APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker,
+    FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField
 )
 from .choices import CircuitStatusChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -17,7 +19,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 # Providers
 #
 
-class ProviderForm(BootstrapMixin, CustomFieldForm):
+class ProviderForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     comments = CommentField()
     tags = TagField(
@@ -46,7 +48,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm):
         }
 
 
-class ProviderCSVForm(forms.ModelForm):
+class ProviderCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
 
     class Meta:
@@ -89,7 +91,8 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdi
         label='Admin contact'
     )
     comments = CommentField(
-        widget=SmallTextarea()
+        widget=SmallTextarea,
+        label='Comments'
     )
 
     class Meta:
@@ -128,6 +131,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         label='ASN'
     )
+    tag = TagFilterField(model)
 
 
 #
@@ -159,7 +163,7 @@ class CircuitTypeCSVForm(forms.ModelForm):
 # Circuits
 #
 
-class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     comments = CommentField()
     tags = TagField(
         required=False
@@ -187,7 +191,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
 
 
-class CircuitCSVForm(forms.ModelForm):
+class CircuitCSVForm(CustomFieldModelCSVForm):
     provider = forms.ModelChoiceField(
         queryset=Provider.objects.all(),
         to_field_name='name',
@@ -332,6 +336,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         min_value=0,
         label='Commit rate (Kbps)'
     )
+    tag = TagFilterField(model)
 
 
 #

+ 77 - 96
netbox/circuits/tests/test_views.py

@@ -1,23 +1,15 @@
-import urllib.parse
-
-from django.test import Client, TestCase
-from django.urls import reverse
+import datetime
 
+from circuits.choices import *
 from circuits.models import Circuit, CircuitType, Provider
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases
 
 
-class ProviderTestCase(TestCase):
+class ProviderTestCase(StandardTestCases.Views):
+    model = Provider
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'circuits.view_provider',
-                'circuits.add_provider',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         Provider.objects.bulk_create([
             Provider(name='Provider 1', slug='provider-1', asn=65001),
@@ -25,48 +17,45 @@ class ProviderTestCase(TestCase):
             Provider(name='Provider 3', slug='provider-3', asn=65003),
         ])
 
-    def test_provider_list(self):
-
-        url = reverse('circuits:provider_list')
-        params = {
-            "q": "test",
+        cls.form_data = {
+            'name': 'Provider X',
+            'slug': 'provider-x',
+            'asn': 65123,
+            'account': '1234',
+            'portal_url': 'http://example.com/portal',
+            'noc_contact': 'noc@example.com',
+            'admin_contact': 'admin@example.com',
+            'comments': 'Another provider',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_provider(self):
-
-        provider = Provider.objects.first()
-        response = self.client.get(provider.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_provider_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Provider 4,provider-4",
             "Provider 5,provider-5",
             "Provider 6,provider-6",
         )
 
-        response = self.client.post(reverse('circuits:provider_import'), {'csv': '\n'.join(csv_data)})
+        cls.bulk_edit_data = {
+            'asn': 65009,
+            'account': '5678',
+            'portal_url': 'http://example.com/portal2',
+            'noc_contact': 'noc2@example.com',
+            'admin_contact': 'admin2@example.com',
+            'comments': 'New comments',
+        }
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Provider.objects.count(), 6)
 
+class CircuitTypeTestCase(StandardTestCases.Views):
+    model = CircuitType
 
-class CircuitTypeTestCase(TestCase):
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
+    test_bulk_edit_objects = None
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'circuits.view_circuittype',
-                'circuits.add_circuittype',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         CircuitType.objects.bulk_create([
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
@@ -74,79 +63,71 @@ class CircuitTypeTestCase(TestCase):
             CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
         ])
 
-    def test_circuittype_list(self):
-
-        url = reverse('circuits:circuittype_list')
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-    def test_circuittype_import(self):
+        cls.form_data = {
+            'name': 'Circuit Type X',
+            'slug': 'circuit-type-x',
+            'description': 'A new circuit type',
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Circuit Type 4,circuit-type-4",
             "Circuit Type 5,circuit-type-5",
             "Circuit Type 6,circuit-type-6",
         )
 
-        response = self.client.post(reverse('circuits:circuittype_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(CircuitType.objects.count(), 6)
+class CircuitTestCase(StandardTestCases.Views):
+    model = Circuit
 
+    @classmethod
+    def setUpTestData(cls):
 
-class CircuitTestCase(TestCase):
-
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'circuits.view_circuit',
-                'circuits.add_circuit',
-            ]
+        providers = (
+            Provider(name='Provider 1', slug='provider-1', asn=65001),
+            Provider(name='Provider 2', slug='provider-2', asn=65002),
         )
-        self.client = Client()
-        self.client.force_login(user)
-
-        provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
-        provider.save()
+        Provider.objects.bulk_create(providers)
 
-        circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1')
-        circuittype.save()
+        circuittypes = (
+            CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
+            CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
+        )
+        CircuitType.objects.bulk_create(circuittypes)
 
         Circuit.objects.bulk_create([
-            Circuit(cid='Circuit 1', provider=provider, type=circuittype),
-            Circuit(cid='Circuit 2', provider=provider, type=circuittype),
-            Circuit(cid='Circuit 3', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]),
+            Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]),
+            Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
         ])
 
-    def test_circuit_list(self):
-
-        url = reverse('circuits:circuit_list')
-        params = {
-            "provider": Provider.objects.first().slug,
-            "type": CircuitType.objects.first().slug,
+        cls.form_data = {
+            'cid': 'Circuit X',
+            'provider': providers[1].pk,
+            'type': circuittypes[1].pk,
+            'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
+            'tenant': None,
+            'install_date': datetime.date(2020, 1, 1),
+            'commit_rate': 1000,
+            'description': 'A new circuit',
+            'comments': 'Some comments',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_circuit(self):
-
-        circuit = Circuit.objects.first()
-        response = self.client.get(circuit.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_circuit_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "cid,provider,type",
             "Circuit 4,Provider 1,Circuit Type 1",
             "Circuit 5,Provider 1,Circuit Type 1",
             "Circuit 6,Provider 1,Circuit Type 1",
         )
 
-        response = self.client.post(reverse('circuits:circuit_import'), {'csv': '\n'.join(csv_data)})
+        cls.bulk_edit_data = {
+            'provider': providers[1].pk,
+            'type': circuittypes[1].pk,
+            'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
+            'tenant': None,
+            'commit_rate': 2000,
+            'description': 'New description',
+            'comments': 'New comments',
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Circuit.objects.count(), 6)
+        }

+ 30 - 30
netbox/circuits/urls.py

@@ -9,42 +9,42 @@ app_name = 'circuits'
 urlpatterns = [
 
     # 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
-    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
-    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
 
-    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}),
 
 ]

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

@@ -15,65 +15,65 @@ router = routers.DefaultRouter()
 router.APIRootView = DCIMRootView
 
 # Field choices
-router.register(r'_choices', views.DCIMFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.DCIMFieldChoicesViewSet, basename='field-choice')
 
 # Sites
-router.register(r'regions', views.RegionViewSet)
-router.register(r'sites', views.SiteViewSet)
+router.register('regions', views.RegionViewSet)
+router.register('sites', views.SiteViewSet)
 
 # 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
-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
-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
-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
-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
-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
-router.register(r'cables', views.CableViewSet)
+router.register('cables', views.CableViewSet)
 
 # Virtual chassis
-router.register(r'virtual-chassis', views.VirtualChassisViewSet)
+router.register('virtual-chassis', views.VirtualChassisViewSet)
 
 # 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
-router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
+router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 
 app_name = 'dcim-api'
 urlpatterns = router.urls

+ 0 - 5790
netbox/dcim/fixtures/dcim.json

@@ -1,5790 +0,0 @@
-[
-{
-    "model": "dcim.site",
-    "pk": 1,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "name": "TEST1",
-        "slug": "test1",
-        "facility": "Test Facility",
-        "asn": 65535,
-        "physical_address": "555 Test Ave.\r\nTest, NY 55555",
-        "shipping_address": "",
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.rack",
-    "pk": 1,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "name": "A1R1",
-        "facility_id": "T23A01",
-        "site": 1,
-        "group": null,
-        "u_height": 42,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.rack",
-    "pk": 2,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "name": "A1R2",
-        "facility_id": "T24A01",
-        "site": 1,
-        "group": null,
-        "u_height": 42,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.manufacturer",
-    "pk": 1,
-    "fields": {
-        "name": "Juniper",
-        "slug": "juniper"
-    }
-},
-{
-    "model": "dcim.manufacturer",
-    "pk": 2,
-    "fields": {
-        "name": "Opengear",
-        "slug": "opengear"
-    }
-},
-{
-    "model": "dcim.manufacturer",
-    "pk": 3,
-    "fields": {
-        "name": "ServerTech",
-        "slug": "servertech"
-    }
-},
-{
-    "model": "dcim.manufacturer",
-    "pk": 4,
-    "fields": {
-        "name": "Dell",
-        "slug": "dell"
-    }
-},
-{
-    "model": "dcim.devicetype",
-    "pk": 1,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "manufacturer": 1,
-        "model": "MX960",
-        "slug": "mx960",
-        "u_height": 16,
-        "is_full_depth": true
-    }
-},
-{
-    "model": "dcim.devicetype",
-    "pk": 2,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "manufacturer": 1,
-        "model": "EX9214",
-        "slug": "ex9214",
-        "u_height": 16,
-        "is_full_depth": true
-    }
-},
-{
-    "model": "dcim.devicetype",
-    "pk": 3,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "manufacturer": 1,
-        "model": "QFX5100-24Q",
-        "slug": "qfx5100-24q",
-        "u_height": 1,
-        "is_full_depth": true
-    }
-},
-{
-    "model": "dcim.devicetype",
-    "pk": 4,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "manufacturer": 1,
-        "model": "QFX5100-48S",
-        "slug": "qfx5100-48s",
-        "u_height": 1,
-        "is_full_depth": true
-    }
-},
-{
-    "model": "dcim.devicetype",
-    "pk": 5,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "manufacturer": 2,
-        "model": "CM4148",
-        "slug": "cm4148",
-        "u_height": 1,
-        "is_full_depth": true
-    }
-},
-{
-    "model": "dcim.devicetype",
-    "pk": 6,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "manufacturer": 3,
-        "model": "CWG-24VYM415C9",
-        "slug": "cwg-24vym415c9",
-        "u_height": 0,
-        "is_full_depth": false
-    }
-},
-{
-    "model": "dcim.devicetype",
-    "pk": 7,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "manufacturer": 4,
-        "model": "PowerEdge R640",
-        "slug": "poweredge-r640",
-        "u_height": 1,
-        "is_full_depth": false
-    }
-},
-{
-    "model": "dcim.consoleporttemplate",
-    "pk": 1,
-    "fields": {
-        "device_type": 1,
-        "name": "Console (RE0)"
-    }
-},
-{
-    "model": "dcim.consoleporttemplate",
-    "pk": 2,
-    "fields": {
-        "device_type": 1,
-        "name": "Console (RE1)"
-    }
-},
-{
-    "model": "dcim.consoleporttemplate",
-    "pk": 3,
-    "fields": {
-        "device_type": 2,
-        "name": "Console (RE0)"
-    }
-},
-{
-    "model": "dcim.consoleporttemplate",
-    "pk": 4,
-    "fields": {
-        "device_type": 2,
-        "name": "Console (RE1)"
-    }
-},
-{
-    "model": "dcim.consoleporttemplate",
-    "pk": 5,
-    "fields": {
-        "device_type": 3,
-        "name": "Console"
-    }
-},
-{
-    "model": "dcim.consoleporttemplate",
-    "pk": 6,
-    "fields": {
-        "device_type": 5,
-        "name": "Console"
-    }
-},
-{
-    "model": "dcim.consoleporttemplate",
-    "pk": 7,
-    "fields": {
-        "device_type": 6,
-        "name": "Serial"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 1,
-    "fields": {
-        "device_type": 3,
-        "name": "Console"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 3,
-    "fields": {
-        "device_type": 4,
-        "name": "Console"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 4,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 1"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 5,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 2"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 6,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 3"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 7,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 4"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 8,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 5"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 9,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 6"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 10,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 7"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 11,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 8"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 12,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 9"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 13,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 10"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 14,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 11"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 15,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 12"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 16,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 13"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 17,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 14"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 18,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 15"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 19,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 16"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 20,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 17"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 21,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 18"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 22,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 19"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 23,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 20"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 24,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 21"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 25,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 22"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 26,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 23"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 27,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 24"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 28,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 25"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 29,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 26"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 30,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 27"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 31,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 28"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 32,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 29"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 33,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 30"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 34,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 31"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 35,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 32"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 36,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 33"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 37,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 34"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 38,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 35"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 39,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 36"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 40,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 37"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 41,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 38"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 42,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 39"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 43,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 40"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 44,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 41"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 45,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 42"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 46,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 43"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 47,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 44"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 48,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 45"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 49,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 46"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 50,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 47"
-    }
-},
-{
-    "model": "dcim.consoleserverporttemplate",
-    "pk": 51,
-    "fields": {
-        "device_type": 5,
-        "name": "Port 48"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 1,
-    "fields": {
-        "device_type": 1,
-        "name": "PEM0"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 2,
-    "fields": {
-        "device_type": 1,
-        "name": "PEM1"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 3,
-    "fields": {
-        "device_type": 1,
-        "name": "PEM2"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 4,
-    "fields": {
-        "device_type": 1,
-        "name": "PEM3"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 5,
-    "fields": {
-        "device_type": 2,
-        "name": "PEM0"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 6,
-    "fields": {
-        "device_type": 2,
-        "name": "PEM1"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 7,
-    "fields": {
-        "device_type": 2,
-        "name": "PEM2"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 8,
-    "fields": {
-        "device_type": 2,
-        "name": "PEM3"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 9,
-    "fields": {
-        "device_type": 4,
-        "name": "PSU0"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 11,
-    "fields": {
-        "device_type": 3,
-        "name": "PSU0"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 12,
-    "fields": {
-        "device_type": 3,
-        "name": "PSU1"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 13,
-    "fields": {
-        "device_type": 4,
-        "name": "PSU1"
-    }
-},
-{
-    "model": "dcim.powerporttemplate",
-    "pk": 14,
-    "fields": {
-        "device_type": 5,
-        "name": "PSU"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 4,
-    "fields": {
-        "device_type": 6,
-        "name": "AA1"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 5,
-    "fields": {
-        "device_type": 6,
-        "name": "AA2"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 6,
-    "fields": {
-        "device_type": 6,
-        "name": "AA3"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 7,
-    "fields": {
-        "device_type": 6,
-        "name": "AA4"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 8,
-    "fields": {
-        "device_type": 6,
-        "name": "AA5"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 9,
-    "fields": {
-        "device_type": 6,
-        "name": "AA6"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 10,
-    "fields": {
-        "device_type": 6,
-        "name": "AA7"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 11,
-    "fields": {
-        "device_type": 6,
-        "name": "AA8"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 12,
-    "fields": {
-        "device_type": 6,
-        "name": "AB1"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 13,
-    "fields": {
-        "device_type": 6,
-        "name": "AB2"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 14,
-    "fields": {
-        "device_type": 6,
-        "name": "AB3"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 15,
-    "fields": {
-        "device_type": 6,
-        "name": "AB4"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 16,
-    "fields": {
-        "device_type": 6,
-        "name": "AB5"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 17,
-    "fields": {
-        "device_type": 6,
-        "name": "AB6"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 18,
-    "fields": {
-        "device_type": 6,
-        "name": "AB7"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 19,
-    "fields": {
-        "device_type": 6,
-        "name": "AB8"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 20,
-    "fields": {
-        "device_type": 6,
-        "name": "AC1"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 21,
-    "fields": {
-        "device_type": 6,
-        "name": "AC2"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 22,
-    "fields": {
-        "device_type": 6,
-        "name": "AC3"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 23,
-    "fields": {
-        "device_type": 6,
-        "name": "AC4"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 24,
-    "fields": {
-        "device_type": 6,
-        "name": "AC5"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 25,
-    "fields": {
-        "device_type": 6,
-        "name": "AC6"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 26,
-    "fields": {
-        "device_type": 6,
-        "name": "AC7"
-    }
-},
-{
-    "model": "dcim.poweroutlettemplate",
-    "pk": 27,
-    "fields": {
-        "device_type": 6,
-        "name": "AC8"
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 1,
-    "fields": {
-        "device_type": 1,
-        "name": "fxp0 (RE0)",
-        "type": 1000,
-        "mgmt_only": true
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 2,
-    "fields": {
-        "device_type": 1,
-        "name": "fxp0 (RE1)",
-        "type": 800,
-        "mgmt_only": true
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 3,
-    "fields": {
-        "device_type": 1,
-        "name": "lo0",
-        "type": 0,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 4,
-    "fields": {
-        "device_type": 2,
-        "name": "fxp0 (RE0)",
-        "type": 1000,
-        "mgmt_only": true
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 5,
-    "fields": {
-        "device_type": 2,
-        "name": "fxp0 (RE1)",
-        "type": 1000,
-        "mgmt_only": true
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 6,
-    "fields": {
-        "device_type": 2,
-        "name": "lo0",
-        "type": 0,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 7,
-    "fields": {
-        "device_type": 3,
-        "name": "em0",
-        "type": 800,
-        "mgmt_only": true
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 8,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/0",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 9,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/1",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 10,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/2",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 11,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/3",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 12,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/4",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 13,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/5",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 14,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/6",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 15,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/7",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 16,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/8",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 17,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/9",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 18,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/10",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 19,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/11",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 20,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/12",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 21,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/13",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 22,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/14",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 23,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/15",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 24,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/16",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 25,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/17",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 26,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/18",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 27,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/19",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 28,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/20",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 29,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/21",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 30,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/0/22",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 31,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/1/0",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 32,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/1/1",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 33,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/1/2",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 34,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/1/3",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 35,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/2/0",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 36,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/2/1",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 37,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/2/2",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 38,
-    "fields": {
-        "device_type": 3,
-        "name": "et-0/2/3",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 138,
-    "fields": {
-        "device_type": 4,
-        "name": "em0",
-        "type": 1000,
-        "mgmt_only": true
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 139,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/0",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 140,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/1",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 141,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/2",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 142,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/3",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 143,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/4",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 144,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/5",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 145,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/6",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 146,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/7",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 147,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/8",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 148,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/9",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 149,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/10",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 150,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/11",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 151,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/12",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 152,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/13",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 153,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/14",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 154,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/15",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 155,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/16",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 156,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/17",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 157,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/18",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 158,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/19",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 159,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/20",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 160,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/21",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 161,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/22",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 162,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/23",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 163,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/24",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 164,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/25",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 165,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/26",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 166,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/27",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 167,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/28",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 168,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/29",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 169,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/30",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 170,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/31",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 171,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/32",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 172,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/33",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 173,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/34",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 174,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/35",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 175,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/36",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 176,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/37",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 177,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/38",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 178,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/39",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 179,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/40",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 180,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/41",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 181,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/42",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 182,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/43",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 183,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/44",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 184,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/45",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 185,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/46",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 186,
-    "fields": {
-        "device_type": 4,
-        "name": "xe-0/0/47",
-        "type": 1200,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 187,
-    "fields": {
-        "device_type": 4,
-        "name": "et-0/0/48",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 188,
-    "fields": {
-        "device_type": 4,
-        "name": "et-0/0/49",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 189,
-    "fields": {
-        "device_type": 4,
-        "name": "et-0/0/50",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 190,
-    "fields": {
-        "device_type": 4,
-        "name": "et-0/0/51",
-        "type": 1400,
-        "mgmt_only": false
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 191,
-    "fields": {
-        "device_type": 5,
-        "name": "eth0",
-        "type": 1000,
-        "mgmt_only": true
-    }
-},
-{
-    "model": "dcim.interfacetemplate",
-    "pk": 192,
-    "fields": {
-        "device_type": 6,
-        "name": "Net",
-        "type": 800,
-        "mgmt_only": true
-    }
-},
-{
-    "model": "dcim.devicerole",
-    "pk": 1,
-    "fields": {
-        "name": "Router",
-        "slug": "router",
-        "color": "purple"
-    }
-},
-{
-    "model": "dcim.devicerole",
-    "pk": 2,
-    "fields": {
-        "name": "Spine Switch",
-        "slug": "spine-switch",
-        "color": "green"
-    }
-},
-{
-    "model": "dcim.devicerole",
-    "pk": 3,
-    "fields": {
-        "name": "Core Switch",
-        "slug": "core-switch",
-        "color": "red"
-    }
-},
-{
-    "model": "dcim.devicerole",
-    "pk": 4,
-    "fields": {
-        "name": "Leaf Switch",
-        "slug": "leaf-switch",
-        "color": "teal"
-    }
-},
-{
-    "model": "dcim.devicerole",
-    "pk": 5,
-    "fields": {
-        "name": "OOB Switch",
-        "slug": "oob-switch",
-        "color": "purple"
-    }
-},
-{
-    "model": "dcim.devicerole",
-    "pk": 6,
-    "fields": {
-        "name": "PDU",
-        "slug": "pdu",
-        "color": "yellow"
-    }
-},
-{
-    "model": "dcim.devicerole",
-    "pk": 7,
-    "fields": {
-        "name": "Server",
-        "slug": "server",
-        "color": "grey"
-    }
-},
-{
-    "model": "dcim.platform",
-    "pk": 1,
-    "fields": {
-        "name": "Juniper Junos",
-        "slug": "juniper-junos"
-    }
-},
-{
-    "model": "dcim.platform",
-    "pk": 2,
-    "fields": {
-        "name": "Opengear",
-        "slug": "opengear"
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 1,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 1,
-        "device_role": 1,
-        "platform": 1,
-        "name": "test1-edge1",
-        "serial": "5555555555",
-        "site": 1,
-        "rack": 1,
-        "position": 1,
-        "face": "front",
-        "status": true,
-        "primary_ip4": 1,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 2,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 2,
-        "device_role": 3,
-        "platform": 1,
-        "name": "test1-core1",
-        "serial": "",
-        "site": 1,
-        "rack": 1,
-        "position": 17,
-        "face": "rear",
-        "status": true,
-        "primary_ip4": 5,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 3,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 3,
-        "device_role": 2,
-        "platform": 1,
-        "name": "test1-spine1",
-        "serial": "",
-        "site": 1,
-        "rack": 1,
-        "position": 33,
-        "face": "rear",
-        "status": true,
-        "primary_ip4": null,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 4,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 4,
-        "device_role": 4,
-        "platform": 1,
-        "name": "test1-leaf1",
-        "serial": "",
-        "site": 1,
-        "rack": 1,
-        "position": 34,
-        "face": "rear",
-        "status": true,
-        "primary_ip4": null,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 5,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 4,
-        "device_role": 4,
-        "platform": 1,
-        "name": "test1-leaf2",
-        "serial": "9823478293748",
-        "site": 1,
-        "rack": 2,
-        "position": 34,
-        "face": "rear",
-        "status": true,
-        "primary_ip4": null,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 6,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 3,
-        "device_role": 2,
-        "platform": 1,
-        "name": "test1-spine2",
-        "serial": "45649818158",
-        "site": 1,
-        "rack": 2,
-        "position": 33,
-        "face": "rear",
-        "status": true,
-        "primary_ip4": null,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 7,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 1,
-        "device_role": 1,
-        "platform": 1,
-        "name": "test1-edge2",
-        "serial": "7567356345",
-        "site": 1,
-        "rack": 2,
-        "position": 1,
-        "face": "rear",
-        "status": true,
-        "primary_ip4": 3,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 8,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 2,
-        "device_role": 3,
-        "platform": 1,
-        "name": "test1-core2",
-        "serial": "67856734534",
-        "site": 1,
-        "rack": 2,
-        "position": 17,
-        "face": "rear",
-        "status": true,
-        "primary_ip4": 19,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 9,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 5,
-        "device_role": 5,
-        "platform": 2,
-        "name": "test1-oob1",
-        "serial": "98273942938",
-        "site": 1,
-        "rack": 1,
-        "position": 42,
-        "face": "rear",
-        "status": true,
-        "primary_ip4": null,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 11,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 6,
-        "device_role": 6,
-        "platform": null,
-        "name": "test1-pdu1",
-        "serial": "",
-        "site": 1,
-        "rack": 1,
-        "position": null,
-        "face": "",
-        "status": true,
-        "primary_ip4": null,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 12,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 6,
-        "device_role": 6,
-        "platform": null,
-        "name": "test1-pdu2",
-        "serial": "",
-        "site": 1,
-        "rack": 2,
-        "position": null,
-        "face": "",
-        "status": true,
-        "primary_ip4": null,
-        "primary_ip6": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.device",
-    "pk": 13,
-    "fields": {
-        "local_context_data": null,
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "device_type": 7,
-        "device_role": 6,
-        "tenant": null,
-        "platform": null,
-        "name": "test1-server1",
-        "serial": "",
-        "asset_tag": null,
-        "site": 1,
-        "rack": 2,
-        "position": null,
-        "face": "",
-        "status": true,
-        "primary_ip4": null,
-        "primary_ip6": null,
-        "cluster": 4,
-        "virtual_chassis": null,
-        "vc_position": null,
-        "vc_priority": null,
-        "comments": ""
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 1,
-    "fields": {
-        "device": 1,
-        "name": "Console (RE0)",
-        "connected_endpoint": 27,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 2,
-    "fields": {
-        "device": 1,
-        "name": "Console (RE1)",
-        "connected_endpoint": 38,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 3,
-    "fields": {
-        "device": 2,
-        "name": "Console (RE0)",
-        "connected_endpoint": 5,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 4,
-    "fields": {
-        "device": 2,
-        "name": "Console (RE1)",
-        "connected_endpoint": 16,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 5,
-    "fields": {
-        "device": 3,
-        "name": "Console",
-        "connected_endpoint": 49,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 6,
-    "fields": {
-        "device": 4,
-        "name": "Console",
-        "connected_endpoint": 48,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 7,
-    "fields": {
-        "device": 5,
-        "name": "Console",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 8,
-    "fields": {
-        "device": 6,
-        "name": "Console",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 9,
-    "fields": {
-        "device": 7,
-        "name": "Console (RE0)",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 10,
-    "fields": {
-        "device": 7,
-        "name": "Console (RE1)",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 11,
-    "fields": {
-        "device": 8,
-        "name": "Console (RE0)",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 12,
-    "fields": {
-        "device": 8,
-        "name": "Console (RE1)",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 13,
-    "fields": {
-        "device": 9,
-        "name": "Console",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 15,
-    "fields": {
-        "device": 11,
-        "name": "Serial",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleport",
-    "pk": 16,
-    "fields": {
-        "device": 12,
-        "name": "Serial",
-        "connected_endpoint": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 5,
-    "fields": {
-        "device": 9,
-        "name": "Port 1"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 6,
-    "fields": {
-        "device": 9,
-        "name": "Port 10"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 7,
-    "fields": {
-        "device": 9,
-        "name": "Port 11"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 8,
-    "fields": {
-        "device": 9,
-        "name": "Port 12"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 9,
-    "fields": {
-        "device": 9,
-        "name": "Port 13"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 10,
-    "fields": {
-        "device": 9,
-        "name": "Port 14"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 11,
-    "fields": {
-        "device": 9,
-        "name": "Port 15"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 12,
-    "fields": {
-        "device": 9,
-        "name": "Port 16"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 13,
-    "fields": {
-        "device": 9,
-        "name": "Port 17"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 14,
-    "fields": {
-        "device": 9,
-        "name": "Port 18"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 15,
-    "fields": {
-        "device": 9,
-        "name": "Port 19"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 16,
-    "fields": {
-        "device": 9,
-        "name": "Port 2"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 17,
-    "fields": {
-        "device": 9,
-        "name": "Port 20"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 18,
-    "fields": {
-        "device": 9,
-        "name": "Port 21"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 19,
-    "fields": {
-        "device": 9,
-        "name": "Port 22"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 20,
-    "fields": {
-        "device": 9,
-        "name": "Port 23"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 21,
-    "fields": {
-        "device": 9,
-        "name": "Port 24"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 22,
-    "fields": {
-        "device": 9,
-        "name": "Port 25"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 23,
-    "fields": {
-        "device": 9,
-        "name": "Port 26"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 24,
-    "fields": {
-        "device": 9,
-        "name": "Port 27"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 25,
-    "fields": {
-        "device": 9,
-        "name": "Port 28"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 26,
-    "fields": {
-        "device": 9,
-        "name": "Port 29"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 27,
-    "fields": {
-        "device": 9,
-        "name": "Port 3"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 28,
-    "fields": {
-        "device": 9,
-        "name": "Port 30"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 29,
-    "fields": {
-        "device": 9,
-        "name": "Port 31"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 30,
-    "fields": {
-        "device": 9,
-        "name": "Port 32"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 31,
-    "fields": {
-        "device": 9,
-        "name": "Port 33"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 32,
-    "fields": {
-        "device": 9,
-        "name": "Port 34"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 33,
-    "fields": {
-        "device": 9,
-        "name": "Port 35"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 34,
-    "fields": {
-        "device": 9,
-        "name": "Port 36"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 35,
-    "fields": {
-        "device": 9,
-        "name": "Port 37"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 36,
-    "fields": {
-        "device": 9,
-        "name": "Port 38"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 37,
-    "fields": {
-        "device": 9,
-        "name": "Port 39"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 38,
-    "fields": {
-        "device": 9,
-        "name": "Port 4"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 39,
-    "fields": {
-        "device": 9,
-        "name": "Port 40"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 40,
-    "fields": {
-        "device": 9,
-        "name": "Port 41"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 41,
-    "fields": {
-        "device": 9,
-        "name": "Port 42"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 42,
-    "fields": {
-        "device": 9,
-        "name": "Port 43"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 43,
-    "fields": {
-        "device": 9,
-        "name": "Port 44"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 44,
-    "fields": {
-        "device": 9,
-        "name": "Port 45"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 45,
-    "fields": {
-        "device": 9,
-        "name": "Port 46"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 46,
-    "fields": {
-        "device": 9,
-        "name": "Port 47"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 47,
-    "fields": {
-        "device": 9,
-        "name": "Port 48"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 48,
-    "fields": {
-        "device": 9,
-        "name": "Port 5"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 49,
-    "fields": {
-        "device": 9,
-        "name": "Port 6"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 50,
-    "fields": {
-        "device": 9,
-        "name": "Port 7"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 51,
-    "fields": {
-        "device": 9,
-        "name": "Port 8"
-    }
-},
-{
-    "model": "dcim.consoleserverport",
-    "pk": 52,
-    "fields": {
-        "device": 9,
-        "name": "Port 9"
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 1,
-    "fields": {
-        "device": 1,
-        "name": "PEM0",
-        "_connected_poweroutlet": 25,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 2,
-    "fields": {
-        "device": 1,
-        "name": "PEM1",
-        "_connected_poweroutlet": 49,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 3,
-    "fields": {
-        "device": 1,
-        "name": "PEM2",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 4,
-    "fields": {
-        "device": 1,
-        "name": "PEM3",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 5,
-    "fields": {
-        "device": 2,
-        "name": "PEM0",
-        "_connected_poweroutlet": 26,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 6,
-    "fields": {
-        "device": 2,
-        "name": "PEM1",
-        "_connected_poweroutlet": 50,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 7,
-    "fields": {
-        "device": 2,
-        "name": "PEM2",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 8,
-    "fields": {
-        "device": 2,
-        "name": "PEM3",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 9,
-    "fields": {
-        "device": 4,
-        "name": "PSU0",
-        "_connected_poweroutlet": 28,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 10,
-    "fields": {
-        "device": 4,
-        "name": "PSU1",
-        "_connected_poweroutlet": 52,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 11,
-    "fields": {
-        "device": 5,
-        "name": "PSU0",
-        "_connected_poweroutlet": 56,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 12,
-    "fields": {
-        "device": 5,
-        "name": "PSU1",
-        "_connected_poweroutlet": 32,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 13,
-    "fields": {
-        "device": 3,
-        "name": "PSU0",
-        "_connected_poweroutlet": 27,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 14,
-    "fields": {
-        "device": 3,
-        "name": "PSU1",
-        "_connected_poweroutlet": 51,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 15,
-    "fields": {
-        "device": 7,
-        "name": "PEM0",
-        "_connected_poweroutlet": 53,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 16,
-    "fields": {
-        "device": 7,
-        "name": "PEM1",
-        "_connected_poweroutlet": 29,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 17,
-    "fields": {
-        "device": 7,
-        "name": "PEM2",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 18,
-    "fields": {
-        "device": 7,
-        "name": "PEM3",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 19,
-    "fields": {
-        "device": 8,
-        "name": "PEM0",
-        "_connected_poweroutlet": 54,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 20,
-    "fields": {
-        "device": 8,
-        "name": "PEM1",
-        "_connected_poweroutlet": 30,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 21,
-    "fields": {
-        "device": 8,
-        "name": "PEM2",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 22,
-    "fields": {
-        "device": 8,
-        "name": "PEM3",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 23,
-    "fields": {
-        "device": 6,
-        "name": "PSU0",
-        "_connected_poweroutlet": 55,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 24,
-    "fields": {
-        "device": 6,
-        "name": "PSU1",
-        "_connected_poweroutlet": 31,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.powerport",
-    "pk": 25,
-    "fields": {
-        "device": 9,
-        "name": "PSU",
-        "_connected_poweroutlet": null,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 25,
-    "fields": {
-        "device": 11,
-        "name": "AA1"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 26,
-    "fields": {
-        "device": 11,
-        "name": "AA2"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 27,
-    "fields": {
-        "device": 11,
-        "name": "AA3"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 28,
-    "fields": {
-        "device": 11,
-        "name": "AA4"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 29,
-    "fields": {
-        "device": 11,
-        "name": "AA5"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 30,
-    "fields": {
-        "device": 11,
-        "name": "AA6"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 31,
-    "fields": {
-        "device": 11,
-        "name": "AA7"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 32,
-    "fields": {
-        "device": 11,
-        "name": "AA8"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 33,
-    "fields": {
-        "device": 11,
-        "name": "AB1"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 34,
-    "fields": {
-        "device": 11,
-        "name": "AB2"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 35,
-    "fields": {
-        "device": 11,
-        "name": "AB3"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 36,
-    "fields": {
-        "device": 11,
-        "name": "AB4"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 37,
-    "fields": {
-        "device": 11,
-        "name": "AB5"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 38,
-    "fields": {
-        "device": 11,
-        "name": "AB6"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 39,
-    "fields": {
-        "device": 11,
-        "name": "AB7"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 40,
-    "fields": {
-        "device": 11,
-        "name": "AB8"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 41,
-    "fields": {
-        "device": 11,
-        "name": "AC1"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 42,
-    "fields": {
-        "device": 11,
-        "name": "AC2"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 43,
-    "fields": {
-        "device": 11,
-        "name": "AC3"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 44,
-    "fields": {
-        "device": 11,
-        "name": "AC4"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 45,
-    "fields": {
-        "device": 11,
-        "name": "AC5"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 46,
-    "fields": {
-        "device": 11,
-        "name": "AC6"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 47,
-    "fields": {
-        "device": 11,
-        "name": "AC7"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 48,
-    "fields": {
-        "device": 11,
-        "name": "AC8"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 49,
-    "fields": {
-        "device": 12,
-        "name": "AA1"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 50,
-    "fields": {
-        "device": 12,
-        "name": "AA2"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 51,
-    "fields": {
-        "device": 12,
-        "name": "AA3"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 52,
-    "fields": {
-        "device": 12,
-        "name": "AA4"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 53,
-    "fields": {
-        "device": 12,
-        "name": "AA5"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 54,
-    "fields": {
-        "device": 12,
-        "name": "AA6"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 55,
-    "fields": {
-        "device": 12,
-        "name": "AA7"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 56,
-    "fields": {
-        "device": 12,
-        "name": "AA8"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 57,
-    "fields": {
-        "device": 12,
-        "name": "AB1"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 58,
-    "fields": {
-        "device": 12,
-        "name": "AB2"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 59,
-    "fields": {
-        "device": 12,
-        "name": "AB3"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 60,
-    "fields": {
-        "device": 12,
-        "name": "AB4"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 61,
-    "fields": {
-        "device": 12,
-        "name": "AB5"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 62,
-    "fields": {
-        "device": 12,
-        "name": "AB6"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 63,
-    "fields": {
-        "device": 12,
-        "name": "AB7"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 64,
-    "fields": {
-        "device": 12,
-        "name": "AB8"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 65,
-    "fields": {
-        "device": 12,
-        "name": "AC1"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 66,
-    "fields": {
-        "device": 12,
-        "name": "AC2"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 67,
-    "fields": {
-        "device": 12,
-        "name": "AC3"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 68,
-    "fields": {
-        "device": 12,
-        "name": "AC4"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 69,
-    "fields": {
-        "device": 12,
-        "name": "AC5"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 70,
-    "fields": {
-        "device": 12,
-        "name": "AC6"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 71,
-    "fields": {
-        "device": 12,
-        "name": "AC7"
-    }
-},
-{
-    "model": "dcim.poweroutlet",
-    "pk": 72,
-    "fields": {
-        "device": 12,
-        "name": "AC8"
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 1,
-    "fields": {
-        "device": 1,
-        "name": "fxp0 (RE0)",
-        "type": 1000,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 2,
-    "fields": {
-        "device": 1,
-        "name": "fxp0 (RE1)",
-        "type": 800,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 3,
-    "fields": {
-        "device": 1,
-        "name": "lo0",
-        "type": 0,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 4,
-    "fields": {
-        "device": 1,
-        "name": "xe-0/0/0",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": "TEST"
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 5,
-    "fields": {
-        "device": 1,
-        "name": "xe-0/0/1",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 6,
-    "fields": {
-        "device": 1,
-        "name": "xe-0/0/2",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 7,
-    "fields": {
-        "device": 1,
-        "name": "xe-0/0/3",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 8,
-    "fields": {
-        "device": 1,
-        "name": "xe-0/0/4",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 9,
-    "fields": {
-        "device": 1,
-        "name": "xe-0/0/5",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 10,
-    "fields": {
-        "device": 2,
-        "name": "fxp0 (RE0)",
-        "type": 1000,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 11,
-    "fields": {
-        "device": 2,
-        "name": "fxp0 (RE1)",
-        "type": 1000,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 12,
-    "fields": {
-        "device": 2,
-        "name": "lo0",
-        "type": 0,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 13,
-    "fields": {
-        "device": 3,
-        "name": "em0",
-        "mac_address": "00-00-00-AA-BB-CC",
-        "type": 800,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 14,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 15,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 16,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/10",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 17,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/11",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 18,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/12",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 19,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/13",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 20,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/14",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 21,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/15",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 22,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/16",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 23,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/17",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 24,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/18",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 25,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/19",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 26,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 27,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/20",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 28,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/21",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 29,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/22",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 30,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/3",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 31,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/4",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 32,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/5",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 33,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/6",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 34,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/7",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 35,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/8",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 36,
-    "fields": {
-        "device": 3,
-        "name": "et-0/0/9",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 37,
-    "fields": {
-        "device": 3,
-        "name": "et-0/1/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 38,
-    "fields": {
-        "device": 3,
-        "name": "et-0/1/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 39,
-    "fields": {
-        "device": 3,
-        "name": "et-0/1/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 40,
-    "fields": {
-        "device": 3,
-        "name": "et-0/1/3",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 41,
-    "fields": {
-        "device": 3,
-        "name": "et-0/2/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 42,
-    "fields": {
-        "device": 3,
-        "name": "et-0/2/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 43,
-    "fields": {
-        "device": 3,
-        "name": "et-0/2/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 44,
-    "fields": {
-        "device": 3,
-        "name": "et-0/2/3",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 45,
-    "fields": {
-        "device": 4,
-        "name": "em0",
-        "type": 1000,
-        "mac_address": "ff-ee-dd-33-22-11",
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 46,
-    "fields": {
-        "device": 4,
-        "name": "et-0/0/48",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 47,
-    "fields": {
-        "device": 4,
-        "name": "et-0/0/49",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 48,
-    "fields": {
-        "device": 4,
-        "name": "et-0/0/50",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 49,
-    "fields": {
-        "device": 4,
-        "name": "et-0/0/51",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 50,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/0",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 51,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/1",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 52,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/10",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 53,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/11",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 54,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/12",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 55,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/13",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 56,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/14",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 57,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/15",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 58,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/16",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 59,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/17",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 60,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/18",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 61,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/19",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 62,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/2",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 63,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/20",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 64,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/21",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 65,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/22",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 66,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/23",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 67,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/24",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 68,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/25",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 69,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/26",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 70,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/27",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 71,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/28",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 72,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/29",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 73,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/3",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 74,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/30",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 75,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/31",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 76,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/32",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 77,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/33",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 78,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/34",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 79,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/35",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 80,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/36",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 81,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/37",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 82,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/38",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 83,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/39",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 84,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/4",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 85,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/40",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 86,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/41",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 87,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/42",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 88,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/43",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 89,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/44",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 90,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/45",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 91,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/46",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 92,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/47",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 93,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/5",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 94,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/6",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 95,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/7",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 96,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/8",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 97,
-    "fields": {
-        "device": 4,
-        "name": "xe-0/0/9",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 98,
-    "fields": {
-        "device": 5,
-        "name": "em0",
-        "type": 1000,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 99,
-    "fields": {
-        "device": 5,
-        "name": "et-0/0/48",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 100,
-    "fields": {
-        "device": 5,
-        "name": "et-0/0/49",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 101,
-    "fields": {
-        "device": 5,
-        "name": "et-0/0/50",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 102,
-    "fields": {
-        "device": 5,
-        "name": "et-0/0/51",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 103,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/0",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 104,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/1",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 105,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/10",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 106,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/11",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 107,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/12",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 108,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/13",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 109,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/14",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 110,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/15",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 111,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/16",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 112,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/17",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 113,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/18",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 114,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/19",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 115,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/2",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 116,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/20",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 117,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/21",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 118,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/22",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 119,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/23",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 120,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/24",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 121,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/25",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 122,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/26",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 123,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/27",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 124,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/28",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 125,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/29",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 126,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/3",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 127,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/30",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 128,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/31",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 129,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/32",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 130,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/33",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 131,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/34",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 132,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/35",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 133,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/36",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 134,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/37",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 135,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/38",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 136,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/39",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 137,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/4",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 138,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/40",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 139,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/41",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 140,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/42",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 141,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/43",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 142,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/44",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 143,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/45",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 144,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/46",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 145,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/47",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 146,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/5",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 147,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/6",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 148,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/7",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 149,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/8",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 150,
-    "fields": {
-        "device": 5,
-        "name": "xe-0/0/9",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 151,
-    "fields": {
-        "device": 6,
-        "name": "em0",
-        "type": 800,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 152,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 153,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 154,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/10",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 155,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/11",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 156,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/12",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 157,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/13",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 158,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/14",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 159,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/15",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 160,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/16",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 161,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/17",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 162,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/18",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 163,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/19",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 164,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 165,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/20",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 166,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/21",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 167,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/22",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 168,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/3",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 169,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/4",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 170,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/5",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 171,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/6",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 172,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/7",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 173,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/8",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 174,
-    "fields": {
-        "device": 6,
-        "name": "et-0/0/9",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 175,
-    "fields": {
-        "device": 6,
-        "name": "et-0/1/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 176,
-    "fields": {
-        "device": 6,
-        "name": "et-0/1/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 177,
-    "fields": {
-        "device": 6,
-        "name": "et-0/1/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 178,
-    "fields": {
-        "device": 6,
-        "name": "et-0/1/3",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 179,
-    "fields": {
-        "device": 6,
-        "name": "et-0/2/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 180,
-    "fields": {
-        "device": 6,
-        "name": "et-0/2/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 181,
-    "fields": {
-        "device": 6,
-        "name": "et-0/2/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 182,
-    "fields": {
-        "device": 6,
-        "name": "et-0/2/3",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 183,
-    "fields": {
-        "device": 7,
-        "name": "fxp0 (RE0)",
-        "type": 1000,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 184,
-    "fields": {
-        "device": 7,
-        "name": "fxp0 (RE1)",
-        "type": 800,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 185,
-    "fields": {
-        "device": 7,
-        "name": "lo0",
-        "type": 0,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 186,
-    "fields": {
-        "device": 8,
-        "name": "fxp0 (RE0)",
-        "type": 1000,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 187,
-    "fields": {
-        "device": 8,
-        "name": "fxp0 (RE1)",
-        "type": 1000,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 188,
-    "fields": {
-        "device": 8,
-        "name": "lo0",
-        "type": 0,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 189,
-    "fields": {
-        "device": 2,
-        "name": "et-0/0/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 190,
-    "fields": {
-        "device": 2,
-        "name": "et-0/0/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 191,
-    "fields": {
-        "device": 2,
-        "name": "et-0/0/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 192,
-    "fields": {
-        "device": 2,
-        "name": "et-0/1/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 193,
-    "fields": {
-        "device": 2,
-        "name": "et-0/1/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 194,
-    "fields": {
-        "device": 2,
-        "name": "et-0/1/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 195,
-    "fields": {
-        "device": 8,
-        "name": "et-0/0/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 196,
-    "fields": {
-        "device": 8,
-        "name": "et-0/0/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 197,
-    "fields": {
-        "device": 8,
-        "name": "et-0/0/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 198,
-    "fields": {
-        "device": 8,
-        "name": "et-0/1/0",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 199,
-    "fields": {
-        "device": 8,
-        "name": "et-0/1/1",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 200,
-    "fields": {
-        "device": 8,
-        "name": "et-0/1/2",
-        "type": 1400,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 201,
-    "fields": {
-        "device": 2,
-        "name": "xe-0/0/0",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 202,
-    "fields": {
-        "device": 2,
-        "name": "xe-0/0/1",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 203,
-    "fields": {
-        "device": 2,
-        "name": "xe-0/0/2",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 204,
-    "fields": {
-        "device": 2,
-        "name": "xe-0/0/3",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 205,
-    "fields": {
-        "device": 2,
-        "name": "xe-0/0/4",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 206,
-    "fields": {
-        "device": 2,
-        "name": "xe-0/0/5",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 207,
-    "fields": {
-        "device": 8,
-        "name": "xe-0/0/0",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 208,
-    "fields": {
-        "device": 8,
-        "name": "xe-0/0/1",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 209,
-    "fields": {
-        "device": 8,
-        "name": "xe-0/0/2",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 210,
-    "fields": {
-        "device": 8,
-        "name": "xe-0/0/3",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 211,
-    "fields": {
-        "device": 8,
-        "name": "xe-0/0/4",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 212,
-    "fields": {
-        "device": 8,
-        "name": "xe-0/0/5",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 213,
-    "fields": {
-        "device": 7,
-        "name": "xe-0/0/0",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 214,
-    "fields": {
-        "device": 7,
-        "name": "xe-0/0/1",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 215,
-    "fields": {
-        "device": 7,
-        "name": "xe-0/0/2",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 216,
-    "fields": {
-        "device": 7,
-        "name": "xe-0/0/3",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 217,
-    "fields": {
-        "device": 7,
-        "name": "xe-0/0/4",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 218,
-    "fields": {
-        "device": 7,
-        "name": "xe-0/0/5",
-        "type": 1200,
-        "mgmt_only": false,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 219,
-    "fields": {
-        "device": 9,
-        "name": "eth0",
-        "type": 1000,
-        "mac_address": "44-55-66-77-88-99",
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 221,
-    "fields": {
-        "device": 11,
-        "name": "Net",
-        "type": 800,
-        "mgmt_only": true,
-        "description": ""
-    }
-},
-{
-    "model": "dcim.interface",
-    "pk": 222,
-    "fields": {
-        "device": 12,
-        "name": "Net",
-        "type": 800,
-        "mgmt_only": true,
-        "description": ""
-    }
-}
-]

Разница между файлами не показана из-за своего большого размера
+ 441 - 67
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.expressions import RawSQL
 
 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):
 
@@ -27,47 +16,4 @@ class InterfaceQuerySet(QuerySet):
 class InterfaceManager(Manager):
 
     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)

+ 1 - 1
netbox/dcim/migrations/0079_3569_rack_fields.py

@@ -37,7 +37,7 @@ def rack_status_to_slug(apps, schema_editor):
 def rack_outer_unit_to_slug(apps, schema_editor):
     Rack = apps.get_model('dcim', 'Rack')
     for id, slug in RACK_DIMENSION_CHOICES:
-        Rack.objects.filter(status=str(id)).update(status=slug)
+        Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug)
 
 
 class Migration(migrations.Migration):

+ 27 - 0
netbox/dcim/migrations/0092_fix_rack_outer_unit.py

@@ -0,0 +1,27 @@
+from django.db import migrations
+
+RACK_DIMENSION_CHOICES = (
+    (1000, 'mm'),
+    (2000, 'in'),
+)
+
+
+def rack_outer_unit_to_slug(apps, schema_editor):
+    Rack = apps.get_model('dcim', 'Rack')
+    for id, slug in RACK_DIMENSION_CHOICES:
+        Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0091_interface_type_other'),
+    ]
+
+    operations = [
+        # Fixes a missed field migration from #3569; see bug #4056. The original migration has also been fixed,
+        # so this can be omitted when squashing in the future.
+        migrations.RunPython(
+            code=rack_outer_unit_to_slug
+        ),
+    ]

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

+ 30 - 24
netbox/dcim/models/__init__.py

@@ -22,8 +22,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import ASNField
 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.utils import foreground_color, to_meters
 from .device_component_templates import (
@@ -134,6 +133,11 @@ class Site(ChangeLoggedModel, CustomFieldModel):
         max_length=50,
         unique=True
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     slug = models.SlugField(
         unique=True
     )
@@ -215,8 +219,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     images = GenericRelation(
         to='extras.ImageAttachment'
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
@@ -235,7 +237,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     }
 
     class Meta:
-        ordering = ['name']
+        ordering = ('_name',)
 
     def __str__(self):
         return self.name
@@ -405,7 +407,7 @@ class RackElevationHelperMixin:
 
     @staticmethod
     def _draw_device_rear(drawing, device, start, end, text):
-        rect = drawing.rect(start, end, class_="blocked")
+        rect = drawing.rect(start, end, class_="slot blocked")
         rect.set_desc('{} — {} ({}U) {} {}'.format(
             device.device_role, device.device_type.display_name,
             device.device_type.u_height, device.asset_tag or '', device.serial or ''
@@ -516,6 +518,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     facility_id = models.CharField(
         max_length=50,
         blank=True,
@@ -612,8 +619,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     images = GenericRelation(
         to='extras.ImageAttachment'
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
@@ -634,12 +639,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     }
 
     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
-            ['group', 'name'],
-            ['group', 'facility_id'],
-        ]
+            ('group', 'name'),
+            ('group', 'facility_id'),
+        )
 
     def __str__(self):
         return self.display_name or super().__str__()
@@ -1018,9 +1023,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
 
     tags = TaggableManager(through=TaggedItem)
 
-    csv_headers = [
-        'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
-    ]
     clone_fields = [
         'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
     ]
@@ -1316,6 +1318,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         blank=True,
         null=True
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True,
+        null=True
+    )
     serial = models.CharField(
         max_length=50,
         blank=True,
@@ -1410,8 +1418,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     images = GenericRelation(
         to='extras.ImageAttachment'
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
@@ -1433,12 +1439,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     }
 
     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 = (
             ('napalm_read', 'Read-only access to devices via NAPALM'),
             ('napalm_write', 'Read/write access to devices via NAPALM'),

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

@@ -4,9 +4,9 @@ from django.db import models
 
 from dcim.choices import *
 from dcim.constants import *
-from dcim.managers import InterfaceManager
 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 .device_components import (
     ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
@@ -58,17 +58,20 @@ class ConsolePortTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
         blank=True
     )
 
-    objects = NaturalOrderingManager()
-
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
     def __str__(self):
         return self.name
@@ -93,17 +96,20 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
         blank=True
     )
 
-    objects = NaturalOrderingManager()
-
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
     def __str__(self):
         return self.name
@@ -128,6 +134,11 @@ class PowerPortTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=PowerPortTypeChoices,
@@ -146,11 +157,9 @@ class PowerPortTemplate(ComponentTemplateModel):
         help_text="Allocated power draw (watts)"
     )
 
-    objects = NaturalOrderingManager()
-
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
     def __str__(self):
         return self.name
@@ -176,6 +185,11 @@ class PowerOutletTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=PowerOutletTypeChoices,
@@ -195,11 +209,9 @@ class PowerOutletTemplate(ComponentTemplateModel):
         help_text="Phase (for three-phase feeds)"
     )
 
-    objects = NaturalOrderingManager()
-
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
     def __str__(self):
         return self.name
@@ -237,6 +249,12 @@ class InterfaceTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=64
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        naturalize_function=naturalize_interface,
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=InterfaceTypeChoices
@@ -246,11 +264,9 @@ class InterfaceTemplate(ComponentTemplateModel):
         verbose_name='Management only'
     )
 
-    objects = InterfaceManager()
-
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
     def __str__(self):
         return self.name
@@ -276,6 +292,11 @@ class FrontPortTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=64
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=PortTypeChoices
@@ -290,14 +311,12 @@ class FrontPortTemplate(ComponentTemplateModel):
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
 
-    objects = NaturalOrderingManager()
-
     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):
         return self.name
@@ -344,6 +363,11 @@ class RearPortTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=64
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=PortTypeChoices
@@ -353,11 +377,9 @@ class RearPortTemplate(ComponentTemplateModel):
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
 
-    objects = NaturalOrderingManager()
-
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
     def __str__(self):
         return self.name
@@ -383,12 +405,15 @@ class DeviceBayTemplate(ComponentTemplateModel):
     name = models.CharField(
         max_length=50
     )
-
-    objects = NaturalOrderingManager()
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
 
     class Meta:
-        ordering = ['device_type', 'name']
-        unique_together = ['device_type', 'name']
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
 
     def __str__(self):
         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.exceptions import LoopDetected
 from dcim.fields import MACAddressField
-from dcim.managers import InterfaceManager
 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 virtualization.choices import VMInterfaceTypeChoices
 
@@ -181,6 +181,11 @@ class ConsolePort(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
@@ -197,15 +202,13 @@ class ConsolePort(CableTermination, ComponentModel):
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'description']
 
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
     def __str__(self):
         return self.name
@@ -238,6 +241,11 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=ConsolePortTypeChoices,
@@ -247,14 +255,13 @@ class ConsoleServerPort(CableTermination, ComponentModel):
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'description']
 
     class Meta:
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
     def __str__(self):
         return self.name
@@ -287,6 +294,11 @@ class PowerPort(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=PowerPortTypeChoices,
@@ -322,15 +334,13 @@ class PowerPort(CableTermination, ComponentModel):
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
 
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
     def __str__(self):
         return self.name
@@ -433,6 +443,11 @@ class PowerOutlet(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=50
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=PowerOutletTypeChoices,
@@ -455,14 +470,13 @@ class PowerOutlet(CableTermination, ComponentModel):
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
 
     class Meta:
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
     def __str__(self):
         return self.name
@@ -515,6 +529,12 @@ class Interface(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=64
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        naturalize_function=naturalize_interface,
+        max_length=100,
+        blank=True
+    )
     _connected_interface = models.OneToOneField(
         to='self',
         on_delete=models.SET_NULL,
@@ -583,8 +603,6 @@ class Interface(CableTermination, ComponentModel):
         blank=True,
         verbose_name='Tagged VLANs'
     )
-
-    objects = InterfaceManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
@@ -593,8 +611,9 @@ class Interface(CableTermination, ComponentModel):
     ]
 
     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):
         return self.name
@@ -761,6 +780,11 @@ class FrontPort(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=64
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=PortTypeChoices
@@ -774,20 +798,17 @@ class FrontPort(CableTermination, ComponentModel):
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
-
-    is_path_endpoint = False
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
+    is_path_endpoint = False
 
     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):
         return self.name
@@ -831,6 +852,11 @@ class RearPort(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=64
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     type = models.CharField(
         max_length=50,
         choices=PortTypeChoices
@@ -839,17 +865,14 @@ class RearPort(CableTermination, ComponentModel):
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
-
-    is_path_endpoint = False
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
+    is_path_endpoint = False
 
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
     def __str__(self):
         return self.name
@@ -881,6 +904,11 @@ class DeviceBay(ComponentModel):
         max_length=50,
         verbose_name='Name'
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     installed_device = models.OneToOneField(
         to='dcim.Device',
         on_delete=models.SET_NULL,
@@ -888,15 +916,13 @@ class DeviceBay(ComponentModel):
         blank=True,
         null=True
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'installed_device', 'description']
 
     class Meta:
-        ordering = ['device', 'name']
-        unique_together = ['device', 'name']
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
 
     def __str__(self):
         return '{} - {}'.format(self.device.name, self.name)
@@ -960,6 +986,11 @@ class InventoryItem(ComponentModel):
         max_length=50,
         verbose_name='Name'
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     manufacturer = models.ForeignKey(
         to='dcim.Manufacturer',
         on_delete=models.PROTECT,
@@ -997,14 +1028,14 @@ class InventoryItem(ComponentModel):
     ]
 
     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):
         return self.name
 
     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):
         return (

+ 20 - 4
netbox/dcim/tables.py

@@ -229,7 +229,7 @@ class RegionTable(BaseTable):
 
 class SiteTable(BaseTable):
     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')
     region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -291,7 +291,7 @@ class RackRoleTable(BaseTable):
 
 class RackTable(BaseTable):
     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')])
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -409,6 +409,7 @@ class DeviceTypeTable(BaseTable):
 
 class ConsolePortTemplateTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('consoleporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -432,6 +433,7 @@ class ConsolePortImportTable(BaseTable):
 
 class ConsoleServerPortTemplateTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('consoleserverporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -440,7 +442,7 @@ class ConsoleServerPortTemplateTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = ConsoleServerPortTemplate
-        fields = ('pk', 'name', 'actions')
+        fields = ('pk', 'name', 'type', 'actions')
         empty_text = "None"
 
 
@@ -455,6 +457,7 @@ class ConsoleServerPortImportTable(BaseTable):
 
 class PowerPortTemplateTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('powerporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -478,6 +481,7 @@ class PowerPortImportTable(BaseTable):
 
 class PowerOutletTemplateTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('poweroutlettemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -526,6 +530,7 @@ class InterfaceImportTable(BaseTable):
 
 class FrontPortTemplateTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     rear_port_position = tables.Column(
         verbose_name='Position'
     )
@@ -552,6 +557,7 @@ class FrontPortImportTable(BaseTable):
 
 class RearPortTemplateTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('rearporttemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -575,6 +581,7 @@ class RearPortImportTable(BaseTable):
 
 class DeviceBayTemplateTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     actions = tables.TemplateColumn(
         template_code=get_component_template_actions('devicebaytemplate'),
         attrs={'td': {'class': 'text-right noprint'}},
@@ -654,7 +661,7 @@ class PlatformTable(BaseTable):
 class DeviceTable(BaseTable):
     pk = ToggleColumn()
     name = tables.TemplateColumn(
-        order_by=('_nat1', '_nat2', '_nat3'),
+        order_by=('_name',),
         template_code=DEVICE_LINK
     )
     status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
@@ -704,6 +711,7 @@ class DeviceImportTable(BaseTable):
 
 class DeviceComponentDetailTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(order_by=('_name',))
     cable = tables.LinkColumn()
 
     class Meta(BaseTable.Meta):
@@ -713,6 +721,7 @@ class DeviceComponentDetailTable(BaseTable):
 
 
 class ConsolePortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
     class Meta(BaseTable.Meta):
         model = ConsolePort
@@ -727,6 +736,7 @@ class ConsolePortDetailTable(DeviceComponentDetailTable):
 
 
 class ConsoleServerPortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
     class Meta(BaseTable.Meta):
         model = ConsoleServerPort
@@ -741,6 +751,7 @@ class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
 
 
 class PowerPortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
     class Meta(BaseTable.Meta):
         model = PowerPort
@@ -755,6 +766,7 @@ class PowerPortDetailTable(DeviceComponentDetailTable):
 
 
 class PowerOutletTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
     class Meta(BaseTable.Meta):
         model = PowerOutlet
@@ -777,6 +789,7 @@ class InterfaceTable(BaseTable):
 
 class InterfaceDetailTable(DeviceComponentDetailTable):
     parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
+    name = tables.LinkColumn()
 
     class Meta(InterfaceTable.Meta):
         order_by = ('parent', 'name')
@@ -785,6 +798,7 @@ class InterfaceDetailTable(DeviceComponentDetailTable):
 
 
 class FrontPortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
     class Meta(BaseTable.Meta):
         model = FrontPort
@@ -800,6 +814,7 @@ class FrontPortDetailTable(DeviceComponentDetailTable):
 
 
 class RearPortTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
     class Meta(BaseTable.Meta):
         model = RearPort
@@ -815,6 +830,7 @@ class RearPortDetailTable(DeviceComponentDetailTable):
 
 
 class DeviceBayTable(BaseTable):
+    name = tables.Column(order_by=('_name',))
 
     class Meta(BaseTable.Meta):
         model = DeviceBay

+ 76 - 50
netbox/dcim/tests/test_forms.py

@@ -2,6 +2,7 @@ from django.test import TestCase
 
 from dcim.forms import *
 from dcim.models import *
+from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 def get_id(model, slug):
@@ -10,83 +11,108 @@ def get_id(model, slug):
 
 class DeviceTestCase(TestCase):
 
-    fixtures = ['dcim', 'ipam', 'virtualization']
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        rack = Rack.objects.create(name='Rack 1', site=site)
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1
+        )
+        device_role = DeviceRole.objects.create(
+            name='Device Role 1', slug='device-role-1', color='ff0000'
+        )
+        Platform.objects.create(name='Platform 1', slug='platform-1')
+        Device.objects.create(
+            name='Device 1', device_type=device_type, device_role=device_role, site=site, rack=rack, position=1
+        )
+        cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        cluster_group = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
+        Cluster.objects.create(name='Cluster 1', type=cluster_type, group=cluster_group)
 
     def test_racked_device(self):
-        test = DeviceForm(data={
-            'name': 'test',
-            'device_role': get_id(DeviceRole, 'leaf-switch'),
+        form = DeviceForm(data={
+            'name': 'New Device',
+            'device_role': DeviceRole.objects.first().pk,
             'tenant': None,
-            'manufacturer': get_id(Manufacturer, 'juniper'),
-            'device_type': get_id(DeviceType, 'qfx5100-48s'),
-            'site': get_id(Site, 'test1'),
-            'rack': '1',
+            'manufacturer': Manufacturer.objects.first().pk,
+            'device_type': DeviceType.objects.first().pk,
+            'site': Site.objects.first().pk,
+            'rack': Rack.objects.first().pk,
             'face': DeviceFaceChoices.FACE_FRONT,
-            'position': 41,
-            'platform': get_id(Platform, 'juniper-junos'),
+            'position': 2,
+            'platform': Platform.objects.first().pk,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
-        self.assertTrue(test.is_valid(), test.fields['position'].choices)
-        self.assertTrue(test.save())
+        self.assertTrue(form.is_valid())
+        self.assertTrue(form.save())
 
     def test_racked_device_occupied(self):
-        test = DeviceForm(data={
+        form = DeviceForm(data={
             'name': 'test',
-            'device_role': get_id(DeviceRole, 'leaf-switch'),
+            'device_role': DeviceRole.objects.first().pk,
             'tenant': None,
-            'manufacturer': get_id(Manufacturer, 'juniper'),
-            'device_type': get_id(DeviceType, 'qfx5100-48s'),
-            'site': get_id(Site, 'test1'),
-            'rack': '1',
+            'manufacturer': Manufacturer.objects.first().pk,
+            'device_type': DeviceType.objects.first().pk,
+            'site': Site.objects.first().pk,
+            'rack': Rack.objects.first().pk,
             'face': DeviceFaceChoices.FACE_FRONT,
             'position': 1,
-            'platform': get_id(Platform, 'juniper-junos'),
+            'platform': Platform.objects.first().pk,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
-        self.assertFalse(test.is_valid())
+        self.assertFalse(form.is_valid())
+        self.assertIn('position', form.errors)
 
     def test_non_racked_device(self):
-        test = DeviceForm(data={
-            'name': 'test',
-            'device_role': get_id(DeviceRole, 'pdu'),
+        form = DeviceForm(data={
+            'name': 'New Device',
+            'device_role': DeviceRole.objects.first().pk,
             'tenant': None,
-            'manufacturer': get_id(Manufacturer, 'servertech'),
-            'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
-            'site': get_id(Site, 'test1'),
-            'rack': '1',
-            'face': '',
+            'manufacturer': Manufacturer.objects.first().pk,
+            'device_type': DeviceType.objects.first().pk,
+            'site': Site.objects.first().pk,
+            'rack': None,
+            'face': None,
             'position': None,
-            'platform': None,
+            'platform': Platform.objects.first().pk,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
-        self.assertTrue(test.is_valid())
-        self.assertTrue(test.save())
+        self.assertTrue(form.is_valid())
+        self.assertTrue(form.save())
 
-    def test_non_racked_device_with_face(self):
-        test = DeviceForm(data={
-            'name': 'test',
-            'device_role': get_id(DeviceRole, 'pdu'),
+    def test_non_racked_device_with_face_position(self):
+        form = DeviceForm(data={
+            'name': 'New Device',
+            'device_role': DeviceRole.objects.first().pk,
             'tenant': None,
-            'manufacturer': get_id(Manufacturer, 'servertech'),
-            'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
-            'site': get_id(Site, 'test1'),
-            'rack': '1',
+            'manufacturer': Manufacturer.objects.first().pk,
+            'device_type': DeviceType.objects.first().pk,
+            'site': Site.objects.first().pk,
+            'rack': None,
             'face': DeviceFaceChoices.FACE_REAR,
-            'position': None,
+            'position': 10,
             'platform': None,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
         })
-        self.assertTrue(test.is_valid())
-        self.assertTrue(test.save())
+        self.assertFalse(form.is_valid())
+        self.assertIn('face', form.errors)
+        self.assertIn('position', form.errors)
 
-    def test_cloned_cluster_device_initial_data(self):
+    def test_initial_data_population(self):
+        device_type = DeviceType.objects.first()
+        cluster = Cluster.objects.first()
         test = DeviceForm(initial={
-            'device_type': get_id(DeviceType, 'poweredge-r640'),
-            'device_role': get_id(DeviceRole, 'server'),
+            'device_type': device_type.pk,
+            'device_role': DeviceRole.objects.first().pk,
             'status': DeviceStatusChoices.STATUS_ACTIVE,
-            'site': get_id(Site, 'test1'),
-            "cluster": Cluster.objects.get(id=4).id,
+            'site': Site.objects.first().pk,
+            'cluster': cluster.pk,
         })
-        self.assertEqual(test.initial['manufacturer'], get_id(Manufacturer, 'dell'))
-        self.assertIn('cluster_group', test.initial)
-        self.assertEqual(test.initial['cluster_group'], get_id(ClusterGroup, 'vm-host'))
+
+        # Check that the initial value for the manufacturer is set automatically when assigning the device type
+        self.assertEqual(test.initial['manufacturer'], device_type.manufacturer.pk)
+
+        # Check that the initial value for the cluster group is set automatically when assigning the cluster
+        self.assertEqual(test.initial['cluster_group'], cluster.group.pk)

Разница между файлами не показана из-за своего большого размера
+ 653 - 231
netbox/dcim/tests/test_views.py


+ 261 - 248
netbox/dcim/urls.py

@@ -14,317 +14,330 @@ app_name = 'dcim'
 urlpatterns = [
 
     # 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
-    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
-    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
-    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
-    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
-    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.RackEditView.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
-    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
-    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
-    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'),
 
     # 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'),
 
     # 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'),
 
     # 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'),
 
     # 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'),
 
     # 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'),
 
     # 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'),
 
     # 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'),
 
     # 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
-    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
-    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
-    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
-    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
-    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
-    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
-    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
-    # 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
-    # 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
-    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
-    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
-    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)
-    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
-    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
-    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
-    path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
-    path(r'power-feeds/add/', views.PowerFeedEditView.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}),
 
 ]

+ 85 - 68
netbox/dcim/views.py

@@ -705,8 +705,6 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = ConsolePortTemplate
     form = forms.ConsolePortTemplateCreateForm
     model_form = forms.ConsolePortTemplateForm
@@ -719,17 +717,21 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.ConsolePortTemplateForm
 
 
+class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_consoleporttemplate'
+    queryset = ConsolePortTemplate.objects.all()
+    table = tables.ConsolePortTemplateTable
+    form = forms.ConsolePortTemplateBulkEditForm
+
+
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleporttemplate'
     queryset = ConsolePortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.ConsolePortTemplateTable
 
 
 class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = ConsoleServerPortTemplate
     form = forms.ConsoleServerPortTemplateCreateForm
     model_form = forms.ConsoleServerPortTemplateForm
@@ -742,17 +744,21 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView)
     model_form = forms.ConsoleServerPortTemplateForm
 
 
+class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_consoleserverporttemplate'
+    queryset = ConsoleServerPortTemplate.objects.all()
+    table = tables.ConsoleServerPortTemplateTable
+    form = forms.ConsoleServerPortTemplateBulkEditForm
+
+
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverporttemplate'
     queryset = ConsoleServerPortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.ConsoleServerPortTemplateTable
 
 
 class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = PowerPortTemplate
     form = forms.PowerPortTemplateCreateForm
     model_form = forms.PowerPortTemplateForm
@@ -765,17 +771,21 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.PowerPortTemplateForm
 
 
+class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_powerporttemplate'
+    queryset = PowerPortTemplate.objects.all()
+    table = tables.PowerPortTemplateTable
+    form = forms.PowerPortTemplateBulkEditForm
+
+
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerporttemplate'
     queryset = PowerPortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.PowerPortTemplateTable
 
 
 class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlettemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = PowerOutletTemplate
     form = forms.PowerOutletTemplateCreateForm
     model_form = forms.PowerOutletTemplateForm
@@ -788,17 +798,21 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.PowerOutletTemplateForm
 
 
+class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_poweroutlettemplate'
+    queryset = PowerOutletTemplate.objects.all()
+    table = tables.PowerOutletTemplateTable
+    form = forms.PowerOutletTemplateBulkEditForm
+
+
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlettemplate'
     queryset = PowerOutletTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.PowerOutletTemplateTable
 
 
 class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interfacetemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = InterfaceTemplate
     form = forms.InterfaceTemplateCreateForm
     model_form = forms.InterfaceTemplateForm
@@ -814,7 +828,6 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView):
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interfacetemplate'
     queryset = InterfaceTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.InterfaceTemplateTable
     form = forms.InterfaceTemplateBulkEditForm
 
@@ -822,14 +835,11 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interfacetemplate'
     queryset = InterfaceTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.InterfaceTemplateTable
 
 
 class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_frontporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = FrontPortTemplate
     form = forms.FrontPortTemplateCreateForm
     model_form = forms.FrontPortTemplateForm
@@ -842,17 +852,21 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.FrontPortTemplateForm
 
 
+class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_frontporttemplate'
+    queryset = FrontPortTemplate.objects.all()
+    table = tables.FrontPortTemplateTable
+    form = forms.FrontPortTemplateBulkEditForm
+
+
 class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_frontporttemplate'
     queryset = FrontPortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.FrontPortTemplateTable
 
 
 class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_rearporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = RearPortTemplate
     form = forms.RearPortTemplateCreateForm
     model_form = forms.RearPortTemplateForm
@@ -865,17 +879,21 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.RearPortTemplateForm
 
 
+class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_rearporttemplate'
+    queryset = RearPortTemplate.objects.all()
+    table = tables.RearPortTemplateTable
+    form = forms.RearPortTemplateBulkEditForm
+
+
 class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rearporttemplate'
     queryset = RearPortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.RearPortTemplateTable
 
 
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebaytemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = DeviceBayTemplate
     form = forms.DeviceBayTemplateCreateForm
     model_form = forms.DeviceBayTemplateForm
@@ -888,10 +906,16 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.DeviceBayTemplateForm
 
 
+# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+#     permission_required = 'dcim.change_devicebaytemplate'
+#     queryset = DeviceBayTemplate.objects.all()
+#     table = tables.DeviceBayTemplateTable
+#     form = forms.DeviceBayTemplateBulkEditForm
+
+
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebaytemplate'
     queryset = DeviceBayTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.DeviceBayTemplateTable
 
 
@@ -1200,13 +1224,11 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/consoleport_list.html'
 
 
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsolePort
     form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
@@ -1231,11 +1253,18 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
     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):
     permission_required = 'dcim.delete_consoleport'
     queryset = ConsolePort.objects.all()
-    parent_model = Device
     table = tables.ConsolePortTable
+    default_return_url = 'dcim:consoleport_list'
 
 
 #
@@ -1248,13 +1277,11 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/consoleserverport_list.html'
 
 
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsoleServerPort
     form = forms.ConsoleServerPortCreateForm
     model_form = forms.ConsoleServerPortForm
@@ -1282,7 +1309,6 @@ class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
-    parent_model = Device
     table = tables.ConsoleServerPortTable
     form = forms.ConsoleServerPortBulkEditForm
 
@@ -1302,8 +1328,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
-    parent_model = Device
     table = tables.ConsoleServerPortTable
+    default_return_url = 'dcim:consoleserverport_list'
 
 
 #
@@ -1316,13 +1342,11 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/powerport_list.html'
 
 
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerport'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerPort
     form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
@@ -1347,11 +1371,18 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
     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):
     permission_required = 'dcim.delete_powerport'
     queryset = PowerPort.objects.all()
-    parent_model = Device
     table = tables.PowerPortTable
+    default_return_url = 'dcim:powerport_list'
 
 
 #
@@ -1364,13 +1395,11 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/poweroutlet_list.html'
 
 
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlet'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerOutlet
     form = forms.PowerOutletCreateForm
     model_form = forms.PowerOutletForm
@@ -1398,7 +1427,6 @@ class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_poweroutlet'
     queryset = PowerOutlet.objects.all()
-    parent_model = Device
     table = tables.PowerOutletTable
     form = forms.PowerOutletBulkEditForm
 
@@ -1418,8 +1446,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     queryset = PowerOutlet.objects.all()
-    parent_model = Device
     table = tables.PowerOutletTable
+    default_return_url = 'dcim:poweroutlet_list'
 
 
 #
@@ -1432,7 +1460,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/interface_list.html'
 
 
 class InterfaceView(PermissionRequiredMixin, View):
@@ -1473,8 +1501,6 @@ class InterfaceView(PermissionRequiredMixin, View):
 
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interface'
-    parent_model = Device
-    parent_field = 'device'
     model = Interface
     form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
@@ -1503,7 +1529,6 @@ class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     queryset = Interface.objects.all()
-    parent_model = Device
     table = tables.InterfaceTable
     form = forms.InterfaceBulkEditForm
 
@@ -1523,8 +1548,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interface'
     queryset = Interface.objects.all()
-    parent_model = Device
     table = tables.InterfaceTable
+    default_return_url = 'dcim:interface_list'
 
 
 #
@@ -1537,13 +1562,11 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/frontport_list.html'
 
 
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_frontport'
-    parent_model = Device
-    parent_field = 'device'
     model = FrontPort
     form = forms.FrontPortCreateForm
     model_form = forms.FrontPortForm
@@ -1571,7 +1594,6 @@ class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_frontport'
     queryset = FrontPort.objects.all()
-    parent_model = Device
     table = tables.FrontPortTable
     form = forms.FrontPortBulkEditForm
 
@@ -1591,8 +1613,8 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_frontport'
     queryset = FrontPort.objects.all()
-    parent_model = Device
     table = tables.FrontPortTable
+    default_return_url = 'dcim:frontport_list'
 
 
 #
@@ -1605,13 +1627,11 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/rearport_list.html'
 
 
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_rearport'
-    parent_model = Device
-    parent_field = 'device'
     model = RearPort
     form = forms.RearPortCreateForm
     model_form = forms.RearPortForm
@@ -1639,7 +1659,6 @@ class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rearport'
     queryset = RearPort.objects.all()
-    parent_model = Device
     table = tables.RearPortTable
     form = forms.RearPortBulkEditForm
 
@@ -1659,8 +1678,8 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rearport'
     queryset = RearPort.objects.all()
-    parent_model = Device
     table = tables.RearPortTable
+    default_return_url = 'dcim:rearport_list'
 
 
 #
@@ -1675,13 +1694,11 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayDetailTable
-    template_name = 'dcim/device_component_list.html'
+    template_name = 'dcim/devicebay_list.html'
 
 
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebay'
-    parent_model = Device
-    parent_field = 'device'
     model = DeviceBay
     form = forms.DeviceBayCreateForm
     model_form = forms.DeviceBayForm
@@ -1784,8 +1801,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
 class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebay'
     queryset = DeviceBay.objects.all()
-    parent_model = Device
     table = tables.DeviceBayTable
+    default_return_url = 'dcim:devicebay_list'
 
 
 #
@@ -2156,13 +2173,13 @@ class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
     model = InventoryItem
     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):

+ 15 - 1
netbox/extras/api/serializers.py

@@ -20,6 +20,8 @@ from utilities.api import (
     ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
     ValidatedModelSerializer,
 )
+from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer
+from virtualization.models import Cluster, ClusterGroup
 from .nested_serializers import *
 
 
@@ -161,6 +163,18 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         required=False,
         many=True
     )
+    cluster_groups = SerializedPKRelatedField(
+        queryset=ClusterGroup.objects.all(),
+        serializer=NestedClusterGroupSerializer,
+        required=False,
+        many=True
+    )
+    clusters = SerializedPKRelatedField(
+        queryset=Cluster.objects.all(),
+        serializer=NestedClusterSerializer,
+        required=False,
+        many=True
+    )
     tenant_groups = SerializedPKRelatedField(
         queryset=TenantGroup.objects.all(),
         serializer=NestedTenantGroupSerializer,
@@ -184,7 +198,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         model = ConfigContext
         fields = [
             'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
-            'tenant_groups', 'tenants', 'tags', 'data',
+            'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
         ]
 
 

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

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

+ 17 - 0
netbox/extras/filters.py

@@ -4,6 +4,7 @@ from django.db.models import Q
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
+from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
 
@@ -170,6 +171,22 @@ class ConfigContextFilterSet(django_filters.FilterSet):
         to_field_name='slug',
         label='Platform (slug)',
     )
+    cluster_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='cluster_groups',
+        queryset=ClusterGroup.objects.all(),
+        label='Cluster group',
+    )
+    cluster_group = django_filters.ModelMultipleChoiceFilter(
+        field_name='cluster_groups__slug',
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='slug',
+        label='Cluster group (slug)',
+    )
+    cluster_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='clusters',
+        queryset=Cluster.objects.all(),
+        label='Cluster',
+    )
     tenant_group_id = django_filters.ModelMultipleChoiceFilter(
         field_name='tenant_groups',
         queryset=TenantGroup.objects.all(),

+ 0 - 35
netbox/extras/fixtures/extras.json

@@ -1,35 +0,0 @@
-[
-{
-    "model": "extras.graph",
-    "pk": 1,
-    "fields": {
-        "type": 300,
-        "weight": 1000,
-        "name": "Site Test Graph",
-        "source": "http://localhost/na.png",
-        "link": ""
-    }
-},
-{
-    "model": "extras.graph",
-    "pk": 2,
-    "fields": {
-        "type": 200,
-        "weight": 1000,
-        "name": "Provider Test Graph",
-        "source": "http://localhost/provider_graph.png",
-        "link": ""
-    }
-},
-{
-    "model": "extras.graph",
-    "pk": 3,
-    "fields": {
-        "type": 100,
-        "weight": 1000,
-        "name": "Interface Test Graph",
-        "source": "http://localhost/interface_graph.png",
-        "link": ""
-    }
-}
-]

+ 75 - 103
netbox/extras/forms.py

@@ -1,18 +1,16 @@
-from collections import OrderedDict
-
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
 from taggit.forms import TagField
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
-    SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
+    CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2,
+    BOOLEAN_WITH_BLANK_CHOICES,
 )
+from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
 
@@ -21,102 +19,41 @@ from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachmen
 # Custom fields
 #
 
-def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
-    """
-    Retrieve all CustomFields applicable to the given ContentType
-    """
-    field_dict = OrderedDict()
-    custom_fields = CustomField.objects.filter(obj_type=content_type)
-    if filterable_only:
-        custom_fields = custom_fields.exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED)
-
-    for cf in custom_fields:
-        field_name = 'cf_{}'.format(str(cf.name))
-        initial = cf.default if not bulk_edit else None
-
-        # Integer
-        if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
-            field = forms.IntegerField(required=cf.required, initial=initial)
-
-        # Boolean
-        elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
-            choices = (
-                (None, '---------'),
-                (1, 'True'),
-                (0, 'False'),
-            )
-            if initial is not None and initial.lower() in ['true', 'yes', '1']:
-                initial = 1
-            elif initial is not None and initial.lower() in ['false', 'no', '0']:
-                initial = 0
-            else:
-                initial = None
-            field = forms.NullBooleanField(
-                required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
-            )
-
-        # Date
-        elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
-            field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
-
-        # Select
-        elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
-            choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
-            if not cf.required or bulk_edit or filterable_only:
-                choices = [(None, '---------')] + choices
-            # Check for a default choice
-            default_choice = None
-            if initial:
-                try:
-                    default_choice = cf.choices.get(value=initial).pk
-                except ObjectDoesNotExist:
-                    pass
-            field = forms.TypedChoiceField(
-                choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
-            )
-
-        # URL
-        elif cf.type == CustomFieldTypeChoices.TYPE_URL:
-            field = LaxURLField(required=cf.required, initial=initial)
-
-        # Text
-        else:
-            field = forms.CharField(max_length=255, required=cf.required, initial=initial)
-
-        field.model = cf
-        field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
-        if cf.description:
-            field.help_text = cf.description
-
-        field_dict[field_name] = field
-
-    return field_dict
-
-
-class CustomFieldForm(forms.ModelForm):
+class CustomFieldModelForm(forms.ModelForm):
 
     def __init__(self, *args, **kwargs):
 
-        self.custom_fields = []
         self.obj_type = ContentType.objects.get_for_model(self._meta.model)
+        self.custom_fields = []
+        self.custom_field_values = {}
 
         super().__init__(*args, **kwargs)
 
-        # Add all applicable CustomFields to the form
-        custom_fields = []
-        for name, field in get_custom_fields_for_model(self.obj_type).items():
-            self.fields[name] = field
-            custom_fields.append(name)
-        self.custom_fields = custom_fields
+        self._append_customfield_fields()
 
-        # If editing an existing object, initialize values for all custom fields
+    def _append_customfield_fields(self):
+        """
+        Append form fields for all CustomFields assigned to this model.
+        """
+        # Retrieve initial CustomField values for the instance
         if self.instance.pk:
-            existing_values = CustomFieldValue.objects.filter(
+            for cfv in CustomFieldValue.objects.filter(
                 obj_type=self.obj_type,
                 obj_id=self.instance.pk
-            ).prefetch_related('field')
-            for cfv in existing_values:
-                self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
+            ).prefetch_related('field'):
+                self.custom_field_values[cfv.field.name] = cfv.serialized_value
+
+        # Append form fields; assign initial values if modifying and existing object
+        for cf in CustomField.objects.filter(obj_type=self.obj_type):
+            field_name = 'cf_{}'.format(cf.name)
+            if self.instance.pk:
+                self.fields[field_name] = cf.to_form_field(set_initial=False)
+                self.fields[field_name].initial = self.custom_field_values.get(cf.name)
+            else:
+                self.fields[field_name] = cf.to_form_field()
+
+            # Annotate the field in the list of CustomField form fields
+            self.custom_fields.append(field_name)
 
     def _save_custom_fields(self):
 
@@ -151,6 +88,19 @@ class CustomFieldForm(forms.ModelForm):
         return obj
 
 
+class CustomFieldModelCSVForm(CustomFieldModelForm):
+
+    def _append_customfield_fields(self):
+
+        # Append form fields
+        for cf in CustomField.objects.filter(obj_type=self.obj_type):
+            field_name = 'cf_{}'.format(cf.name)
+            self.fields[field_name] = cf.to_form_field(for_csv_import=True)
+
+            # Annotate the field in the list of CustomField form fields
+            self.custom_fields.append(field_name)
+
+
 class CustomFieldBulkEditForm(BulkEditForm):
 
     def __init__(self, *args, **kwargs):
@@ -160,15 +110,14 @@ class CustomFieldBulkEditForm(BulkEditForm):
         self.obj_type = ContentType.objects.get_for_model(self.model)
 
         # Add all applicable CustomFields to the form
-        custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
-        for name, field in custom_fields:
+        custom_fields = CustomField.objects.filter(obj_type=self.obj_type)
+        for cf in custom_fields:
             # Annotate non-required custom fields as nullable
-            if not field.required:
-                self.nullable_fields.append(name)
-            field.required = False
-            self.fields[name] = field
+            if not cf.required:
+                self.nullable_fields.append(cf.name)
+            self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
             # Annotate this as a custom field
-            self.custom_fields.append(name)
+            self.custom_fields.append(cf.name)
 
 
 class CustomFieldFilterForm(forms.Form):
@@ -180,10 +129,12 @@ class CustomFieldFilterForm(forms.Form):
         super().__init__(*args, **kwargs)
 
         # Add all applicable CustomFields to the form
-        custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items()
-        for name, field in custom_fields:
-            field.required = False
-            self.fields[name] = field
+        custom_fields = CustomField.objects.filter(obj_type=self.obj_type).exclude(
+            filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
+        )
+        for cf in custom_fields:
+            field_name = 'cf_{}'.format(cf.name)
+            self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
 
 
 #
@@ -254,8 +205,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ConfigContext
         fields = [
-            'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
-            'tenants', 'tags', 'data',
+            'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
+            'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
         ]
         widgets = {
             'regions': APISelectMultiple(
@@ -270,6 +221,12 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
             '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/"
             ),
@@ -340,6 +297,21 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
             value_field="slug",
         )
     )
+    cluster_group = FilterChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/cluster-groups/",
+            value_field="slug",
+        )
+    )
+    cluster_id = FilterChoiceField(
+        queryset=Cluster.objects.all(),
+        label='Cluster',
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/clusters/",
+        )
+    )
     tenant_group = FilterChoiceField(
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',

+ 24 - 0
netbox/extras/migrations/0037_configcontexts_clusters.py

@@ -0,0 +1,24 @@
+# Generated by Django 2.2.8 on 2020-01-17 18:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0013_deterministic_ordering'),
+        ('extras', '0036_contenttype_filters_to_q_objects'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='configcontext',
+            name='cluster_groups',
+            field=models.ManyToManyField(blank=True, related_name='_configcontext_cluster_groups_+', to='virtualization.ClusterGroup'),
+        ),
+        migrations.AddField(
+            model_name='configcontext',
+            name='clusters',
+            field=models.ManyToManyField(blank=True, related_name='_configcontext_clusters_+', to='virtualization.Cluster'),
+        ),
+    ]

+ 81 - 0
netbox/extras/models.py

@@ -1,6 +1,7 @@
 from collections import OrderedDict
 from datetime import date
 
+from django import forms
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
@@ -14,6 +15,7 @@ from django.utils.text import slugify
 from taggit.models import TagBase, GenericTaggedItemBase
 
 from utilities.fields import ColorField
+from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
 from utilities.utils import deepmerge, render_jinja2
 from .choices import *
 from .constants import *
@@ -280,6 +282,75 @@ class CustomField(models.Model):
             return self.choices.get(pk=int(serialized_value))
         return serialized_value
 
+    def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
+        """
+        Return a form field suitable for setting a CustomField's value for an object.
+
+        set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
+        enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
+        for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
+        """
+        initial = self.default if set_initial else None
+        required = self.required if enforce_required else False
+
+        # Integer
+        if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
+            field = forms.IntegerField(required=required, initial=initial)
+
+        # Boolean
+        elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+            choices = (
+                (None, '---------'),
+                (1, 'True'),
+                (0, 'False'),
+            )
+            if initial is not None and initial.lower() in ['true', 'yes', '1']:
+                initial = 1
+            elif initial is not None and initial.lower() in ['false', 'no', '0']:
+                initial = 0
+            else:
+                initial = None
+            field = forms.NullBooleanField(
+                required=required, initial=initial, widget=StaticSelect2(choices=choices)
+            )
+
+        # Date
+        elif self.type == CustomFieldTypeChoices.TYPE_DATE:
+            field = forms.DateField(required=required, initial=initial, widget=DatePicker())
+
+        # Select
+        elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
+            choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
+
+            if not required:
+                choices = add_blank_choice(choices)
+
+            # Set the initial value to the PK of the default choice, if any
+            if set_initial:
+                default_choice = self.choices.filter(value=self.default).first()
+                if default_choice:
+                    initial = default_choice.pk
+
+            field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
+            field = field_class(
+                choices=choices, required=required, initial=initial, widget=StaticSelect2()
+            )
+
+        # URL
+        elif self.type == CustomFieldTypeChoices.TYPE_URL:
+            field = LaxURLField(required=required, initial=initial)
+
+        # Text
+        else:
+            field = forms.CharField(max_length=255, required=required, initial=initial)
+
+        field.model = self
+        field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
+        if self.description:
+            field.help_text = self.description
+
+        return field
+
 
 class CustomFieldValue(models.Model):
     field = models.ForeignKey(
@@ -694,6 +765,16 @@ class ConfigContext(models.Model):
         related_name='+',
         blank=True
     )
+    cluster_groups = models.ManyToManyField(
+        to='virtualization.ClusterGroup',
+        related_name='+',
+        blank=True
+    )
+    clusters = models.ManyToManyField(
+        to='virtualization.Cluster',
+        related_name='+',
+        blank=True
+    )
     tenant_groups = models.ManyToManyField(
         to='tenancy.TenantGroup',
         related_name='+',

+ 6 - 0
netbox/extras/querysets.py

@@ -29,6 +29,10 @@ class ConfigContextQuerySet(QuerySet):
         # `device_role` for Device; `role` for VirtualMachine
         role = getattr(obj, 'device_role', None) or obj.role
 
+        # Virtualization cluster for VirtualMachine
+        cluster = getattr(obj, 'cluster', None)
+        cluster_group = getattr(cluster, 'group', None)
+
         # Get the group of the assigned tenant, if any
         tenant_group = obj.tenant.group if obj.tenant else None
 
@@ -44,6 +48,8 @@ class ConfigContextQuerySet(QuerySet):
             Q(sites=obj.site) | Q(sites=None),
             Q(roles=role) | Q(roles=None),
             Q(platforms=obj.platform) | Q(platforms=None),
+            Q(cluster_groups=cluster_group) | Q(cluster_groups=None),
+            Q(clusters=cluster) | Q(clusters=None),
             Q(tenant_groups=tenant_group) | Q(tenant_groups=None),
             Q(tenants=obj.tenant) | Q(tenants=None),
             Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),

+ 5 - 4
netbox/extras/scripts.py

@@ -53,14 +53,15 @@ class ScriptVariable:
         # Initialize field attributes
         if not hasattr(self, 'field_attrs'):
             self.field_attrs = {}
-        if description:
-            self.field_attrs['help_text'] = description
         if label:
             self.field_attrs['label'] = label
+        if description:
+            self.field_attrs['help_text'] = description
         if default:
             self.field_attrs['initial'] = default
-        if required:
-            self.field_attrs['required'] = True
+        self.field_attrs['required'] = required
+
+        # Initialize the list of optional validators if none have already been defined
         if 'validators' not in self.field_attrs:
             self.field_attrs['validators'] = []
 

+ 113 - 2
netbox/extras/tests/test_customfields.py

@@ -1,14 +1,15 @@
 from datetime import date
 
 from django.contrib.contenttypes.models import ContentType
-from django.test import TestCase
+from django.test import Client, TestCase
 from django.urls import reverse
 from rest_framework import status
 
+from dcim.forms import SiteCSVForm
 from dcim.models import Site
 from extras.choices import *
 from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
-from utilities.testing import APITestCase
+from utilities.testing import APITestCase, create_test_user
 from virtualization.models import VirtualMachine
 
 
@@ -364,3 +365,113 @@ class CustomFieldChoiceAPITest(APITestCase):
         self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
         self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
         self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
+
+
+class CustomFieldImportTest(TestCase):
+
+    def setUp(self):
+
+        user = create_test_user(
+            permissions=[
+                'dcim.view_site',
+                'dcim.add_site',
+            ]
+        )
+        self.client = Client()
+        self.client.force_login(user)
+
+    @classmethod
+    def setUpTestData(cls):
+
+        custom_fields = (
+            CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
+            CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
+            CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
+            CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
+            CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
+            CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT),
+        )
+        for cf in custom_fields:
+            cf.save()
+            cf.obj_type.set([ContentType.objects.get_for_model(Site)])
+
+        CustomFieldChoice.objects.bulk_create((
+            CustomFieldChoice(field=custom_fields[5], value='Choice A'),
+            CustomFieldChoice(field=custom_fields[5], value='Choice B'),
+            CustomFieldChoice(field=custom_fields[5], value='Choice C'),
+        ))
+
+    def test_import(self):
+        """
+        Import a Site in CSV format, including a value for each CustomField.
+        """
+        data = (
+            ('name', 'slug', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'),
+            ('Site 1', 'site-1', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'),
+            ('Site 2', 'site-2', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'),
+            ('Site 3', 'site-3', '', '', '', '', '', ''),
+        )
+        csv_data = '\n'.join(','.join(row) for row in data)
+
+        response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
+        self.assertEqual(response.status_code, 200)
+
+        # Validate data for site 1
+        custom_field_values = {
+            cf.name: value for cf, value in Site.objects.get(name='Site 1').get_custom_fields().items()
+        }
+        self.assertEqual(len(custom_field_values), 6)
+        self.assertEqual(custom_field_values['text'], 'ABC')
+        self.assertEqual(custom_field_values['integer'], 123)
+        self.assertEqual(custom_field_values['boolean'], True)
+        self.assertEqual(custom_field_values['date'], date(2020, 1, 1))
+        self.assertEqual(custom_field_values['url'], 'http://example.com/1')
+        self.assertEqual(custom_field_values['select'].value, 'Choice A')
+
+        # Validate data for site 2
+        custom_field_values = {
+            cf.name: value for cf, value in Site.objects.get(name='Site 2').get_custom_fields().items()
+        }
+        self.assertEqual(len(custom_field_values), 6)
+        self.assertEqual(custom_field_values['text'], 'DEF')
+        self.assertEqual(custom_field_values['integer'], 456)
+        self.assertEqual(custom_field_values['boolean'], False)
+        self.assertEqual(custom_field_values['date'], date(2020, 1, 2))
+        self.assertEqual(custom_field_values['url'], 'http://example.com/2')
+        self.assertEqual(custom_field_values['select'].value, 'Choice B')
+
+        # No CustomFieldValues should be created for site 3
+        obj_type = ContentType.objects.get_for_model(Site)
+        site3 = Site.objects.get(name='Site 3')
+        self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists())
+        self.assertEqual(CustomFieldValue.objects.count(), 12)  # Sanity check
+
+    def test_import_missing_required(self):
+        """
+        Attempt to import an object missing a required custom field.
+        """
+        # Set one of our CustomFields to required
+        CustomField.objects.filter(name='text').update(required=True)
+
+        form_data = {
+            'name': 'Site 1',
+            'slug': 'site-1',
+        }
+
+        form = SiteCSVForm(data=form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn('cf_text', form.errors)
+
+    def test_import_invalid_choice(self):
+        """
+        Attempt to import an object with an invalid choice selection.
+        """
+        form_data = {
+            'name': 'Site 1',
+            'slug': 'site-1',
+            'cf_select': 'Choice X'
+        }
+
+        form = SiteCSVForm(data=form_data)
+        self.assertFalse(form.is_valid())
+        self.assertIn('cf_select', form.errors)

+ 30 - 0
netbox/extras/tests/test_filters.py

@@ -7,6 +7,7 @@ from extras.constants import GRAPH_MODELS
 from extras.filters import *
 from extras.models import ConfigContext, ExportTemplate, Graph
 from tenancy.models import Tenant, TenantGroup
+from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 class GraphTestCase(TestCase):
@@ -107,6 +108,21 @@ class ConfigContextTestCase(TestCase):
         )
         Platform.objects.bulk_create(platforms)
 
+        cluster_groups = (
+            ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
+            ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
+            ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
+        )
+        ClusterGroup.objects.bulk_create(cluster_groups)
+
+        cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        clusters = (
+            Cluster(name='Cluster 1', type=cluster_type),
+            Cluster(name='Cluster 2', type=cluster_type),
+            Cluster(name='Cluster 3', type=cluster_type),
+        )
+        Cluster.objects.bulk_create(clusters)
+
         tenant_groups = (
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
@@ -132,6 +148,8 @@ class ConfigContextTestCase(TestCase):
             c.sites.set([sites[i]])
             c.roles.set([device_roles[i]])
             c.platforms.set([platforms[i]])
+            c.cluster_groups.set([cluster_groups[i]])
+            c.clusters.set([clusters[i]])
             c.tenant_groups.set([tenant_groups[i]])
             c.tenants.set([tenants[i]])
 
@@ -173,6 +191,18 @@ class ConfigContextTestCase(TestCase):
         params = {'platform': [platforms[0].slug, platforms[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_cluster_group(self):
+        cluster_groups = ClusterGroup.objects.all()[:2]
+        params = {'cluster_group_id': [cluster_groups[0].pk, cluster_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_cluster(self):
+        clusters = Cluster.objects.all()[:2]
+        params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_tenant_group(self):
         tenant_groups = TenantGroup.objects.all()[:2]
         params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}

+ 58 - 42
netbox/extras/tests/test_views.py

@@ -2,86 +2,102 @@ import urllib.parse
 import uuid
 
 from django.contrib.auth.models import User
-from django.test import Client, TestCase
 from django.urls import reverse
 
 from dcim.models import Site
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ConfigContext, ObjectChange, Tag
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases, TestCase
 
 
-class TagTestCase(TestCase):
+class TagTestCase(StandardTestCases.Views):
+    model = Tag
 
-    def setUp(self):
-        user = create_test_user(permissions=['extras.view_tag'])
-        self.client = Client()
-        self.client.force_login(user)
+    # Disable inapplicable tests
+    test_create_object = None
+    test_import_objects = None
 
-        Tag.objects.bulk_create([
+    @classmethod
+    def setUpTestData(cls):
+
+        Tag.objects.bulk_create((
             Tag(name='Tag 1', slug='tag-1'),
             Tag(name='Tag 2', slug='tag-2'),
             Tag(name='Tag 3', slug='tag-3'),
-        ])
+        ))
 
-    def test_tag_list(self):
+        cls.form_data = {
+            'name': 'Tag X',
+            'slug': 'tag-x',
+            'color': 'c0c0c0',
+            'comments': 'Some comments',
+        }
 
-        url = reverse('extras:tag_list')
-        params = {
-            "q": "tag",
+        cls.bulk_edit_data = {
+            'color': '00ff00',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
 
+class ConfigContextTestCase(StandardTestCases.Views):
+    model = ConfigContext
 
-class ConfigContextTestCase(TestCase):
+    # Disable inapplicable tests
+    test_import_objects = None
 
-    def setUp(self):
-        user = create_test_user(permissions=['extras.view_configcontext'])
-        self.client = Client()
-        self.client.force_login(user)
+    # TODO: Resolve model discrepancies when creating/editing ConfigContexts
+    test_create_object = None
+    test_edit_object = None
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
 
         # Create three ConfigContexts
         for i in range(1, 4):
             configcontext = ConfigContext(
                 name='Config Context {}'.format(i),
-                data='{{"foo": {}}}'.format(i)
+                data={'foo': i}
             )
             configcontext.save()
             configcontext.sites.add(site)
 
-    def test_configcontext_list(self):
-
-        url = reverse('extras:configcontext_list')
-        params = {
-            "q": "foo",
+        cls.form_data = {
+            'name': 'Config Context X',
+            'weight': 200,
+            'description': 'A new config context',
+            'is_active': True,
+            'regions': [],
+            'sites': [site.pk],
+            'roles': [],
+            'platforms': [],
+            'tenant_groups': [],
+            'tenants': [],
+            'tags': [],
+            'data': '{"foo": 123}',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_configcontext(self):
-
-        configcontext = ConfigContext.objects.first()
-        response = self.client.get(configcontext.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
+        cls.bulk_edit_data = {
+            'weight': 300,
+            'is_active': False,
+            'description': 'New description',
+        }
 
 
+# TODO: Convert to StandardTestCases.Views
 class ObjectChangeTestCase(TestCase):
+    user_permissions = (
+        'extras.view_objectchange',
+    )
 
-    def setUp(self):
-        user = create_test_user(permissions=['extras.view_objectchange'])
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         site = Site(name='Site 1', slug='site-1')
         site.save()
 
         # Create three ObjectChanges
+        user = User.objects.create_user(username='testuser2')
         for i in range(1, 4):
             oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
             oc.user = user
@@ -96,10 +112,10 @@ class ObjectChangeTestCase(TestCase):
         }
 
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)
 
     def test_objectchange(self):
 
         objectchange = ObjectChange.objects.first()
         response = self.client.get(objectchange.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)

+ 23 - 23
netbox/extras/urls.py

@@ -8,38 +8,38 @@ app_name = 'extras'
 urlpatterns = [
 
     # 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/<slug:slug>/', views.TagView.as_view(), name='tag'),
-    path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
-    path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
-    path(r'tags/<slug: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
-    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
-    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
-    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
-    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
-    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'),
 
 ]

+ 3 - 3
netbox/extras/views.py

@@ -37,7 +37,8 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
     template_name = 'extras/tag_list.html'
 
 
-class TagView(View):
+class TagView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_tag'
 
     def get(self, request, slug):
 
@@ -84,10 +85,9 @@ class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
     ).order_by(
         'name'
     )
-    # filter = filters.ProviderFilter
     table = TagTable
     form = forms.TagBulkEditForm
-    default_return_url = 'circuits:provider_list'
+    default_return_url = 'extras:tag_list'
 
 
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):

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

@@ -237,7 +237,7 @@ class AvailableIPSerializer(serializers.Serializer):
 # Services
 #
 
-class ServiceSerializer(CustomFieldModelSerializer):
+class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
     protocol = ChoiceField(choices=ServiceProtocolChoices)
@@ -247,10 +247,11 @@ class ServiceSerializer(CustomFieldModelSerializer):
         required=False,
         many=True
     )
+    tags = TagListSerializerField(required=False)
 
     class Meta:
         model = Service
         fields = [
-            'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description',
+            'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]

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

@@ -15,30 +15,30 @@ router = routers.DefaultRouter()
 router.APIRootView = IPAMRootView
 
 # Field choices
-router.register(r'_choices', views.IPAMFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.IPAMFieldChoicesViewSet, basename='field-choice')
 
 # VRFs
-router.register(r'vrfs', views.VRFViewSet)
+router.register('vrfs', views.VRFViewSet)
 
 # RIRs
-router.register(r'rirs', views.RIRViewSet)
+router.register('rirs', views.RIRViewSet)
 
 # Aggregates
-router.register(r'aggregates', views.AggregateViewSet)
+router.register('aggregates', views.AggregateViewSet)
 
 # 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
-router.register(r'ip-addresses', views.IPAddressViewSet)
+router.register('ip-addresses', views.IPAddressViewSet)
 
 # 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
-router.register(r'services', views.ServiceViewSet)
+router.register('services', views.ServiceViewSet)
 
 app_name = 'ipam-api'
 urlpatterns = router.urls

+ 0 - 329
netbox/ipam/fixtures/ipam.json

@@ -1,329 +0,0 @@
-[
-{
-    "model": "ipam.rir",
-    "pk": 1,
-    "fields": {
-        "name": "RFC1918",
-        "slug": "rfc1918"
-    }
-},
-{
-    "model": "ipam.aggregate",
-    "pk": 1,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "prefix": "10.0.0.0/8",
-        "rir": 1,
-        "date_added": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.role",
-    "pk": 1,
-    "fields": {
-        "name": "Lab Network",
-        "slug": "lab-network",
-        "weight": 1000
-    }
-},
-{
-    "model": "ipam.prefix",
-    "pk": 1,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "prefix": "10.1.1.0/24",
-        "site": 1,
-        "vrf": null,
-        "vlan": null,
-        "status": "active",
-        "role": 1,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.prefix",
-    "pk": 2,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "prefix": "10.0.255.0/24",
-        "site": 1,
-        "vrf": null,
-        "vlan": null,
-        "status": "active",
-        "role": 1,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 1,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.0.255.1/32",
-        "vrf": null,
-        "interface_id": 3,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 2,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "169.254.254.1/31",
-        "vrf": null,
-        "interface_id": 4,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 3,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.0.255.2/32",
-        "vrf": null,
-        "interface_id": 185,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 4,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "169.254.1.1/31",
-        "vrf": null,
-        "interface_id": 213,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 5,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.0.254.1/24",
-        "vrf": null,
-        "interface_id": 12,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 8,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.15.21.1/31",
-        "vrf": null,
-        "interface_id": 218,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 9,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.15.21.2/31",
-        "vrf": null,
-        "interface_id": 9,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 10,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.15.22.1/31",
-        "vrf": null,
-        "interface_id": 8,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 11,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.15.20.1/31",
-        "vrf": null,
-        "interface_id": 7,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 12,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.16.20.1/31",
-        "vrf": null,
-        "interface_id": 216,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 13,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.15.22.2/31",
-        "vrf": null,
-        "interface_id": 206,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 14,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.16.22.1/31",
-        "vrf": null,
-        "interface_id": 217,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 15,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.16.22.2/31",
-        "vrf": null,
-        "interface_id": 205,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 16,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.16.20.2/31",
-        "vrf": null,
-        "interface_id": 211,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 17,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.15.22.2/31",
-        "vrf": null,
-        "interface_id": 212,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 19,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "10.0.254.2/32",
-        "vrf": null,
-        "interface_id": 188,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 20,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "169.254.1.1/31",
-        "vrf": null,
-        "interface_id": 200,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.ipaddress",
-    "pk": 21,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "family": 4,
-        "address": "169.254.1.2/31",
-        "vrf": null,
-        "interface_id": 194,
-        "nat_inside": null,
-        "description": ""
-    }
-},
-{
-    "model": "ipam.vlan",
-    "pk": 1,
-    "fields": {
-        "created": "2016-06-23",
-        "last_updated": "2016-06-23T03:19:56.521Z",
-        "site": 1,
-        "vid": 999,
-        "name": "TEST",
-        "status": "active",
-        "role": 1
-    }
-}
-]

+ 34 - 15
netbox/ipam/forms.py

@@ -4,13 +4,15 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from taggit.forms import TagField
 
 from dcim.models import Device, Interface, Rack, Region, Site
-from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
+)
 from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
     CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm,
-    SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
+    SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES
 )
 from virtualization.models import VirtualMachine
 from .constants import *
@@ -31,7 +33,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 # VRFs
 #
 
-class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     tags = TagField(
         required=False
     )
@@ -49,7 +51,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
 
 
-class VRFCSVForm(forms.ModelForm):
+class VRFCSVForm(CustomFieldModelCSVForm):
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
@@ -103,6 +105,7 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         required=False,
         label='Search'
     )
+    tag = TagFilterField(model)
 
 
 #
@@ -144,7 +147,7 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
 # Aggregates
 #
 
-class AggregateForm(BootstrapMixin, CustomFieldForm):
+class AggregateForm(BootstrapMixin, CustomFieldModelForm):
     tags = TagField(
         required=False
     )
@@ -166,7 +169,7 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
         }
 
 
-class AggregateCSVForm(forms.ModelForm):
+class AggregateCSVForm(CustomFieldModelCSVForm):
     rir = forms.ModelChoiceField(
         queryset=RIR.objects.all(),
         to_field_name='name',
@@ -232,6 +235,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
             value_field="slug",
         )
     )
+    tag = TagFilterField(model)
 
 
 #
@@ -263,7 +267,7 @@ class RoleCSVForm(forms.ModelForm):
 # Prefixes
 #
 
-class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
@@ -341,7 +345,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         self.fields['vrf'].empty_label = 'Global'
 
 
-class PrefixCSVForm(forms.ModelForm):
+class PrefixCSVForm(CustomFieldModelCSVForm):
     vrf = FlexibleModelChoiceField(
         queryset=VRF.objects.all(),
         to_field_name='rd',
@@ -578,13 +582,14 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         required=False,
         label='Expand prefix hierarchy'
     )
+    tag = TagFilterField(model)
 
 
 #
 # IP addresses
 #
 
-class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
+class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
     interface = forms.ModelChoiceField(
         queryset=Interface.objects.all(),
         required=False
@@ -635,6 +640,17 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
             }
         )
     )
+    nat_vrf = forms.ModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF',
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/",
+            filter_for={
+                'nat_inside': 'vrf_id'
+            }
+        )
+    )
     nat_inside = ChainedModelChoiceField(
         queryset=IPAddress.objects.all(),
         chains=(
@@ -740,7 +756,7 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
     )
 
 
-class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
     class Meta:
         model = IPAddress
@@ -760,7 +776,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         self.fields['vrf'].empty_label = 'Global'
 
 
-class IPAddressCSVForm(forms.ModelForm):
+class IPAddressCSVForm(CustomFieldModelCSVForm):
     vrf = FlexibleModelChoiceField(
         queryset=VRF.objects.all(),
         to_field_name='rd',
@@ -1006,6 +1022,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    tag = TagFilterField(model)
 
 
 #
@@ -1076,7 +1093,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 # VLANs
 #
 
-class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
@@ -1124,7 +1141,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
 
 
-class VLANCSVForm(forms.ModelForm):
+class VLANCSVForm(CustomFieldModelCSVForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
@@ -1293,13 +1310,14 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
             null_option=True,
         )
     )
+    tag = TagFilterField(model)
 
 
 #
 # Services
 #
 
-class ServiceForm(BootstrapMixin, CustomFieldForm):
+class ServiceForm(BootstrapMixin, CustomFieldModelForm):
     port = forms.IntegerField(
         min_value=SERVICE_PORT_MIN,
         max_value=SERVICE_PORT_MAX
@@ -1353,6 +1371,7 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     port = forms.IntegerField(
         required=False,
     )
+    tag = TagFilterField(model)
 
 
 class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -1379,5 +1398,5 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
     class Meta:
         nullable_fields = [
-            'site', 'tenant', 'role', 'description',
+            'description',
         ]

+ 1 - 1
netbox/ipam/migrations/0029_3569_ipaddress_fields.py

@@ -2,10 +2,10 @@ from django.db import migrations, models
 
 
 IPADDRESS_STATUS_CHOICES = (
-    (0, 'container'),
     (1, 'active'),
     (2, 'reserved'),
     (3, 'deprecated'),
+    (5, 'dhcp'),
 )
 
 IPADDRESS_ROLE_CHOICES = (

+ 21 - 0
netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py

@@ -0,0 +1,21 @@
+from django.db import migrations
+
+
+def ipaddress_status_dhcp_to_slug(apps, schema_editor):
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+    IPAddress.objects.filter(status='5').update(status='dhcp')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0033_deterministic_ordering'),
+    ]
+
+    operations = [
+        # Fixes a missed integer substitution from #3569; see bug #4027. The original migration has also been fixed,
+        # so this can be omitted when squashing in the future.
+        migrations.RunPython(
+            code=ipaddress_status_dhcp_to_slug
+        ),
+    ]

+ 3 - 0
netbox/ipam/tests/test_api.py

@@ -1064,6 +1064,7 @@ class ServiceTest(APITestCase):
             'name': 'Test Service 4',
             'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
             'port': 4,
+            'tags': ['Foo', 'Bar'],
         }
 
         url = reverse('ipam-api:service-list')
@@ -1076,6 +1077,8 @@ class ServiceTest(APITestCase):
         self.assertEqual(service4.name, data['name'])
         self.assertEqual(service4.protocol, data['protocol'])
         self.assertEqual(service4.port, data['port'])
+        tags = [tag.name for tag in service4.tags.all()]
+        self.assertEqual(sorted(tags), sorted(data['tags']))
 
     def test_create_service_bulk(self):
 

+ 226 - 276
netbox/ipam/tests/test_views.py

@@ -1,26 +1,18 @@
-from netaddr import IPNetwork
-import urllib.parse
+import datetime
 
-from django.test import Client, TestCase
-from django.urls import reverse
+from netaddr import IPNetwork
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
-from ipam.choices import ServiceProtocolChoices
+from ipam.choices import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases
 
 
-class VRFTestCase(TestCase):
+class VRFTestCase(StandardTestCases.Views):
+    model = VRF
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_vrf',
-                'ipam.add_vrf',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         VRF.objects.bulk_create([
             VRF(name='VRF 1', rd='65000:1'),
@@ -28,48 +20,39 @@ class VRFTestCase(TestCase):
             VRF(name='VRF 3', rd='65000:3'),
         ])
 
-    def test_vrf_list(self):
-
-        url = reverse('ipam:vrf_list')
-        params = {
-            "q": "65000",
+        cls.form_data = {
+            'name': 'VRF X',
+            'rd': '65000:999',
+            'tenant': None,
+            'enforce_unique': True,
+            'description': 'A new VRF',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_vrf(self):
-
-        vrf = VRF.objects.first()
-        response = self.client.get(vrf.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_vrf_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name",
             "VRF 4",
             "VRF 5",
             "VRF 6",
         )
 
-        response = self.client.post(reverse('ipam:vrf_import'), {'csv': '\n'.join(csv_data)})
+        cls.bulk_edit_data = {
+            'tenant': None,
+            'enforce_unique': False,
+            'description': 'New description',
+        }
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(VRF.objects.count(), 6)
 
+class RIRTestCase(StandardTestCases.Views):
+    model = RIR
 
-class RIRTestCase(TestCase):
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
+    test_bulk_edit_objects = None
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_rir',
-                'ipam.add_rir',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         RIR.objects.bulk_create([
             RIR(name='RIR 1', slug='rir-1'),
@@ -77,91 +60,71 @@ class RIRTestCase(TestCase):
             RIR(name='RIR 3', slug='rir-3'),
         ])
 
-    def test_rir_list(self):
-
-        url = reverse('ipam:rir_list')
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-    def test_rir_import(self):
+        cls.form_data = {
+            'name': 'RIR X',
+            'slug': 'rir-x',
+            'is_private': True,
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "RIR 4,rir-4",
             "RIR 5,rir-5",
             "RIR 6,rir-6",
         )
 
-        response = self.client.post(reverse('ipam:rir_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(RIR.objects.count(), 6)
+class AggregateTestCase(StandardTestCases.Views):
+    model = Aggregate
 
+    @classmethod
+    def setUpTestData(cls):
 
-class AggregateTestCase(TestCase):
-
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_aggregate',
-                'ipam.add_aggregate',
-            ]
+        rirs = (
+            RIR(name='RIR 1', slug='rir-1'),
+            RIR(name='RIR 2', slug='rir-2'),
         )
-        self.client = Client()
-        self.client.force_login(user)
-
-        rir = RIR(name='RIR 1', slug='rir-1')
-        rir.save()
+        RIR.objects.bulk_create(rirs)
 
         Aggregate.objects.bulk_create([
-            Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir),
-            Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir),
-            Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir),
+            Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]),
+            Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]),
+            Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
         ])
 
-    def test_aggregate_list(self):
-
-        url = reverse('ipam:aggregate_list')
-        params = {
-            "rir": RIR.objects.first().slug,
+        cls.form_data = {
+            'family': 4,
+            'prefix': IPNetwork('10.99.0.0/16'),
+            'rir': rirs[1].pk,
+            'date_added': datetime.date(2020, 1, 1),
+            'description': 'A new aggregate',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_aggregate(self):
-
-        aggregate = Aggregate.objects.first()
-        response = self.client.get(aggregate.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_aggregate_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "prefix,rir",
             "10.4.0.0/16,RIR 1",
             "10.5.0.0/16,RIR 1",
             "10.6.0.0/16,RIR 1",
         )
 
-        response = self.client.post(reverse('ipam:aggregate_import'), {'csv': '\n'.join(csv_data)})
+        cls.bulk_edit_data = {
+            'rir': rirs[1].pk,
+            'date_added': datetime.date(2020, 1, 1),
+            'description': 'New description',
+        }
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Aggregate.objects.count(), 6)
 
+class RoleTestCase(StandardTestCases.Views):
+    model = Role
 
-class RoleTestCase(TestCase):
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
+    test_bulk_edit_objects = None
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_role',
-                'ipam.add_role',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
         Role.objects.bulk_create([
             Role(name='Role 1', slug='role-1'),
@@ -169,146 +132,140 @@ class RoleTestCase(TestCase):
             Role(name='Role 3', slug='role-3'),
         ])
 
-    def test_role_list(self):
-
-        url = reverse('ipam:role_list')
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
-
-    def test_role_import(self):
+        cls.form_data = {
+            'name': 'Role X',
+            'slug': 'role-x',
+            'weight': 200,
+            'description': 'A new role',
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug,weight",
             "Role 4,role-4,1000",
             "Role 5,role-5,1000",
             "Role 6,role-6,1000",
         )
 
-        response = self.client.post(reverse('ipam:role_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Role.objects.count(), 6)
+class PrefixTestCase(StandardTestCases.Views):
+    model = Prefix
 
+    @classmethod
+    def setUpTestData(cls):
 
-class PrefixTestCase(TestCase):
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_prefix',
-                'ipam.add_prefix',
-            ]
+        vrfs = (
+            VRF(name='VRF 1', rd='65000:1'),
+            VRF(name='VRF 2', rd='65000:2'),
         )
-        self.client = Client()
-        self.client.force_login(user)
+        VRF.objects.bulk_create(vrfs)
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+        roles = (
+            Role(name='Role 1', slug='role-1'),
+            Role(name='Role 2', slug='role-2'),
+        )
 
         Prefix.objects.bulk_create([
-            Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site),
-            Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site),
-            Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site),
+            Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
+            Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
+            Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
         ])
 
-    def test_prefix_list(self):
-
-        url = reverse('ipam:prefix_list')
-        params = {
-            "site": Site.objects.first().slug,
+        cls.form_data = {
+            'prefix': IPNetwork('192.0.2.0/24'),
+            'site': sites[1].pk,
+            'vrf': vrfs[1].pk,
+            'tenant': None,
+            'vlan': None,
+            'status': PrefixStatusChoices.STATUS_RESERVED,
+            'role': roles[1].pk,
+            'is_pool': True,
+            'description': 'A new prefix',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_prefix(self):
-
-        prefix = Prefix.objects.first()
-        response = self.client.get(prefix.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_prefix_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "prefix,status",
             "10.4.0.0/16,Active",
             "10.5.0.0/16,Active",
             "10.6.0.0/16,Active",
         )
 
-        response = self.client.post(reverse('ipam:prefix_import'), {'csv': '\n'.join(csv_data)})
+        cls.bulk_edit_data = {
+            'site': sites[1].pk,
+            'vrf': vrfs[1].pk,
+            'tenant': None,
+            'status': PrefixStatusChoices.STATUS_RESERVED,
+            'role': roles[1].pk,
+            'is_pool': False,
+            'description': 'New description',
+        }
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Prefix.objects.count(), 6)
 
+class IPAddressTestCase(StandardTestCases.Views):
+    model = IPAddress
 
-class IPAddressTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_ipaddress',
-                'ipam.add_ipaddress',
-            ]
+        vrfs = (
+            VRF(name='VRF 1', rd='65000:1'),
+            VRF(name='VRF 2', rd='65000:2'),
         )
-        self.client = Client()
-        self.client.force_login(user)
-
-        vrf = VRF(name='VRF 1', rd='65000:1')
-        vrf.save()
 
         IPAddress.objects.bulk_create([
-            IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrf),
-            IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrf),
-            IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrf),
+            IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]),
+            IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]),
+            IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
         ])
 
-    def test_ipaddress_list(self):
-
-        url = reverse('ipam:ipaddress_list')
-        params = {
-            "vrf": VRF.objects.first().rd,
+        cls.form_data = {
+            'vrf': vrfs[1].pk,
+            'address': IPNetwork('192.0.2.99/24'),
+            'tenant': None,
+            'status': IPAddressStatusChoices.STATUS_RESERVED,
+            'role': IPAddressRoleChoices.ROLE_ANYCAST,
+            'interface': None,
+            'nat_inside': None,
+            'dns_name': 'example',
+            'description': 'A new IP address',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_ipaddress(self):
-
-        ipaddress = IPAddress.objects.first()
-        response = self.client.get(ipaddress.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_ipaddress_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "address,status",
             "192.0.2.4/24,Active",
             "192.0.2.5/24,Active",
             "192.0.2.6/24,Active",
         )
 
-        response = self.client.post(reverse('ipam:ipaddress_import'), {'csv': '\n'.join(csv_data)})
+        cls.bulk_edit_data = {
+            'vrf': vrfs[1].pk,
+            'tenant': None,
+            'status': IPAddressStatusChoices.STATUS_RESERVED,
+            'role': IPAddressRoleChoices.ROLE_ANYCAST,
+            'dns_name': 'example',
+            'description': 'New description',
+        }
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(IPAddress.objects.count(), 6)
 
+class VLANGroupTestCase(StandardTestCases.Views):
+    model = VLANGroup
 
-class VLANGroupTestCase(TestCase):
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
+    test_bulk_edit_objects = None
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_vlangroup',
-                'ipam.add_vlangroup',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
 
         VLANGroup.objects.bulk_create([
             VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
@@ -316,104 +273,96 @@ class VLANGroupTestCase(TestCase):
             VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site),
         ])
 
-    def test_vlangroup_list(self):
-
-        url = reverse('ipam:vlangroup_list')
-        params = {
-            "site": Site.objects.first().slug,
+        cls.form_data = {
+            'name': 'VLAN Group X',
+            'slug': 'vlan-group-x',
+            'site': site.pk,
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_vlangroup_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "VLAN Group 4,vlan-group-4",
             "VLAN Group 5,vlan-group-5",
             "VLAN Group 6,vlan-group-6",
         )
 
-        response = self.client.post(reverse('ipam:vlangroup_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(VLANGroup.objects.count(), 6)
+class VLANTestCase(StandardTestCases.Views):
+    model = VLAN
 
+    @classmethod
+    def setUpTestData(cls):
 
-class VLANTestCase(TestCase):
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'ipam.view_vlan',
-                'ipam.add_vlan',
-            ]
+        vlangroups = (
+            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
+            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
         )
-        self.client = Client()
-        self.client.force_login(user)
+        VLANGroup.objects.bulk_create(vlangroups)
 
-        vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
-        vlangroup.save()
+        roles = (
+            Role(name='Role 1', slug='role-1'),
+            Role(name='Role 2', slug='role-2'),
+        )
+        Role.objects.bulk_create(roles)
 
         VLAN.objects.bulk_create([
-            VLAN(group=vlangroup, vid=101, name='VLAN101'),
-            VLAN(group=vlangroup, vid=102, name='VLAN102'),
-            VLAN(group=vlangroup, vid=103, name='VLAN103'),
+            VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]),
+            VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]),
+            VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
         ])
 
-    def test_vlan_list(self):
-
-        url = reverse('ipam:vlan_list')
-        params = {
-            "group": VLANGroup.objects.first().slug,
+        cls.form_data = {
+            'site': sites[1].pk,
+            'group': vlangroups[1].pk,
+            'vid': 999,
+            'name': 'VLAN999',
+            'tenant': None,
+            'status': VLANStatusChoices.STATUS_RESERVED,
+            'role': roles[1].pk,
+            'description': 'A new VLAN',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_vlan(self):
-
-        vlan = VLAN.objects.first()
-        response = self.client.get(vlan.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
-    def test_vlan_import(self):
-
-        csv_data = (
+        cls.csv_data = (
             "vid,name,status",
             "104,VLAN104,Active",
             "105,VLAN105,Active",
             "106,VLAN106,Active",
         )
 
-        response = self.client.post(reverse('ipam:vlan_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(VLAN.objects.count(), 6)
-
-
-class ServiceTestCase(TestCase):
+        cls.bulk_edit_data = {
+            'site': sites[1].pk,
+            'group': vlangroups[1].pk,
+            'tenant': None,
+            'status': VLANStatusChoices.STATUS_RESERVED,
+            'role': roles[1].pk,
+            'description': 'New description',
+        }
 
-    def setUp(self):
-        user = create_test_user(permissions=['ipam.view_service'])
-        self.client = Client()
-        self.client.force_login(user)
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+class ServiceTestCase(StandardTestCases.Views):
+    model = Service
 
-        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
-        manufacturer.save()
+    # Disable inapplicable tests
+    test_import_objects = None
 
-        devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
-        devicetype.save()
+    # TODO: Resolve URL for Service creation
+    test_create_object = None
 
-        devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
-        devicerole.save()
+    @classmethod
+    def setUpTestData(cls):
 
-        device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
-        device.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
 
         Service.objects.bulk_create([
             Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101),
@@ -421,18 +370,19 @@ class ServiceTestCase(TestCase):
             Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103),
         ])
 
-    def test_service_list(self):
-
-        url = reverse('ipam:service_list')
-        params = {
-            "device_id": Device.objects.first(),
+        cls.form_data = {
+            'device': device.pk,
+            'virtual_machine': None,
+            'name': 'Service X',
+            'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
+            'port': 999,
+            'ipaddresses': [],
+            'description': 'A new service',
+            'tags': 'Alpha,Bravo,Charlie',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
-
-    def test_service(self):
-
-        service = Service.objects.first()
-        response = self.client.get(service.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
+        cls.bulk_edit_data = {
+            'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
+            'port': 888,
+            'description': 'New description',
+        }

+ 76 - 76
netbox/ipam/urls.py

@@ -8,97 +8,97 @@ app_name = 'ipam'
 urlpatterns = [
 
     # 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
-    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
-    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
-    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
-    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
-    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
-    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
-    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
-    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}),
 
 ]

+ 3 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
 # Environment setup
 #
 
-VERSION = '2.7.3-dev'
+VERSION = '2.7.5-dev'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -74,6 +74,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DEBUG = getattr(configuration, 'DEBUG', False)
+DEVELOPER = getattr(configuration, 'DEVELOPER', False)
 EMAIL = getattr(configuration, 'EMAIL', {})
 ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
@@ -503,6 +504,7 @@ SWAGGER_SETTINGS = {
         'utilities.custom_inspectors.IdInFilterInspector',
         'drf_yasg.inspectors.CoreAPICompatInspector',
     ],
+    'DEFAULT_INFO': 'netbox.urls.openapi_info',
     'DEFAULT_MODEL_DEPTH': 1,
     'DEFAULT_PAGINATOR_INSPECTORS': [
         'utilities.custom_inspectors.NullablePaginatorInspector',

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

@@ -1,6 +1,6 @@
 import urllib.parse
 
-from django.test import TestCase
+from utilities.testing import TestCase
 from django.urls import reverse
 
 
@@ -11,7 +11,7 @@ class HomeViewTestCase(TestCase):
         url = reverse('home')
 
         response = self.client.get(url)
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)
 
     def test_search(self):
 
@@ -21,4 +21,4 @@ class HomeViewTestCase(TestCase):
         }
 
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)

+ 36 - 34
netbox/netbox/urls.py

@@ -9,14 +9,16 @@ from netbox.views import APIRootView, HomeView, SearchView
 from users.views import LoginView, LogoutView
 from .admin import admin_site
 
+openapi_info = openapi.Info(
+    title="NetBox API",
+    default_version='v2',
+    description="API to access NetBox",
+    terms_of_service="https://github.com/netbox-community/netbox",
+    license=openapi.License(name="Apache v2 License"),
+)
+
 schema_view = get_schema_view(
-    openapi.Info(
-        title="NetBox API",
-        default_version='v2',
-        description="API to access NetBox",
-        terms_of_service="https://github.com/netbox-community/netbox",
-        license=openapi.License(name="Apache v2 License"),
-    ),
+    openapi_info,
     validators=['flex', 'ssv'],
     public=True,
 )
@@ -24,49 +26,49 @@ schema_view = get_schema_view(
 _patterns = [
 
     # 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
-    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
-    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
-    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'),
 
     # 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
-    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:
     import debug_toolbar
     _patterns += [
-        path(r'__debug__/', include(debug_toolbar.urls)),
+        path('__debug__/', include(debug_toolbar.urls)),
     ]
 
 if settings.METRICS_ENABLED:
@@ -76,7 +78,7 @@ if settings.METRICS_ENABLED:
 
 # Prepend BASE_PATH
 urlpatterns = [
-    path(r'{}'.format(settings.BASE_PATH), include(_patterns))
+    path('{}'.format(settings.BASE_PATH), include(_patterns))
 ]
 
 handler500 = 'utilities.views.server_error'

+ 1 - 1
netbox/netbox/views.py

@@ -252,7 +252,7 @@ class HomeView(View):
             'search_form': SearchForm(),
             'stats': stats,
             '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]
         })
 
 

+ 11 - 0
netbox/project-static/js/configcontext.js

@@ -0,0 +1,11 @@
+$('.rendered-context-format').on('click', function() {
+    if (!$(this).hasClass('active')) {
+        // Update selection in the button group
+        $('span.rendered-context-format').removeClass('active');
+        $('span.rendered-context-format[data-format=' + $(this).data('format') + ']').addClass('active');
+
+        // Hide all rendered contexts and only show the selected one
+        $('div.rendered-context-data').hide();
+        $('div.rendered-context-data[data-format=' + $(this).data('format') + ']').show();
+    }
+});

+ 9 - 4
netbox/project-static/js/interface_toggles.js

@@ -2,9 +2,9 @@
 $('button.toggle-ips').click(function() {
     var selected = $(this).attr('selected');
     if (selected) {
-        $('#interfaces_table tr.ipaddresses').hide();
+        $('#interfaces_table tr.interface:visible + tr.ipaddresses').hide();
     } else {
-        $('#interfaces_table tr.ipaddresses').show();
+        $('#interfaces_table tr.interface:visible + tr.ipaddresses').show();
     }
     $(this).attr('selected', !selected);
     $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
@@ -14,17 +14,22 @@ $('button.toggle-ips').click(function() {
 // Inteface filtering
 $('input.interface-filter').on('input', function() {
     var filter = new RegExp(this.value);
+    var interface;
 
-    for (interface of $(this).closest('div.panel').find('tbody > tr')) {
+    for (interface of $('#interfaces_table > tbody > tr.interface')) {
         // Slice off 'interface_' at the start of the ID
-        if (filter && filter.test(interface.id.slice(10))) {
+        if (filter.test(interface.id.slice(10))) {
             // Match the toggle in case the filter now matches the interface
             $(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
             $(interface).show();
+            if ($('button.toggle-ips').attr('selected')) {
+                $(interface).next('tr.ipaddresses').show();
+            }
         } else {
             // Uncheck to prevent actions from including it when it doesn't match
             $(interface).find('input:checkbox[name=pk]').prop('checked', false);
             $(interface).hide();
+            $(interface).next('tr.ipaddresses').hide();
         }
     }
 });

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

@@ -15,15 +15,15 @@ router = routers.DefaultRouter()
 router.APIRootView = SecretsRootView
 
 # Field choices
-router.register(r'_choices', views.SecretsFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.SecretsFieldChoicesViewSet, basename='field-choice')
 
 # 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
-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'
 urlpatterns = router.urls

+ 7 - 4
netbox/secrets/forms.py

@@ -4,10 +4,12 @@ from django import forms
 from taggit.forms import TagField
 
 from dcim.models import Device
-from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm
+from extras.forms import (
+    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
+)
 from utilities.forms import (
     APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField,
-    StaticSelect2Multiple
+    StaticSelect2Multiple, TagFilterField
 )
 from .constants import *
 from .models import Secret, SecretRole, UserKey
@@ -68,7 +70,7 @@ class SecretRoleCSVForm(forms.ModelForm):
 # Secrets
 #
 
-class SecretForm(BootstrapMixin, CustomFieldForm):
+class SecretForm(BootstrapMixin, CustomFieldModelForm):
     plaintext = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
@@ -116,7 +118,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
             })
 
 
-class SecretCSVForm(forms.ModelForm):
+class SecretCSVForm(CustomFieldModelCSVForm):
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -187,6 +189,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
             value_field="slug",
         )
     )
+    tag = TagFilterField(model)
 
 
 #

+ 68 - 77
netbox/secrets/tests/test_views.py

@@ -1,26 +1,23 @@
 import base64
-import urllib.parse
 
-from django.test import Client, TestCase
 from django.urls import reverse
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.testing import create_test_user
+from utilities.testing import StandardTestCases
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 
 
-class SecretRoleTestCase(TestCase):
+class SecretRoleTestCase(StandardTestCases.Views):
+    model = SecretRole
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'secrets.view_secretrole',
-                'secrets.add_secretrole',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    # Disable inapplicable tests
+    test_get_object = None
+    test_delete_object = None
+    test_bulk_edit_objects = None
+
+    @classmethod
+    def setUpTestData(cls):
 
         SecretRole.objects.bulk_create([
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
@@ -28,89 +25,83 @@ class SecretRoleTestCase(TestCase):
             SecretRole(name='Secret Role 3', slug='secret-role-3'),
         ])
 
-    def test_secretrole_list(self):
-
-        url = reverse('secrets:secretrole_list')
-
-        response = self.client.get(url, follow=True)
-        self.assertEqual(response.status_code, 200)
-
-    def test_secretrole_import(self):
+        cls.form_data = {
+            'name': 'Secret Role X',
+            'slug': 'secret-role-x',
+            'description': 'A secret role',
+            'users': [],
+            'groups': [],
+        }
 
-        csv_data = (
+        cls.csv_data = (
             "name,slug",
             "Secret Role 4,secret-role-4",
             "Secret Role 5,secret-role-5",
             "Secret Role 6,secret-role-6",
         )
 
-        response = self.client.post(reverse('secrets:secretrole_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(SecretRole.objects.count(), 6)
-
-
-class SecretTestCase(TestCase):
-
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'secrets.view_secret',
-                'secrets.add_secret',
-            ]
-        )
-
-        # Set up a master key
-        userkey = UserKey(user=user, public_key=PUBLIC_KEY)
-        userkey.save()
-        master_key = userkey.get_master_key(PRIVATE_KEY)
-        self.session_key = SessionKey(userkey=userkey)
-        self.session_key.save(master_key)
-
-        self.client = Client()
-        self.client.force_login(user)
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+class SecretTestCase(StandardTestCases.Views):
+    model = Secret
 
-        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
-        manufacturer.save()
+    # Disable inapplicable tests
+    test_create_object = None
 
-        devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
-        devicetype.save()
+    # TODO: Check permissions enforcement on secrets.views.secret_edit
+    test_edit_object = None
 
-        devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
-        devicerole.save()
+    @classmethod
+    def setUpTestData(cls):
 
-        device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
-        device.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
 
-        secretrole = SecretRole(name='Secret Role 1', slug='secret-role-1')
-        secretrole.save()
-
-        Secret.objects.bulk_create([
-            Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'),
-            Secret(device=device, role=secretrole, name='Secret 2', ciphertext=b'1234567890'),
-            Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'),
-        ])
+        devices = (
+            Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
+        )
+        Device.objects.bulk_create(devices)
 
-    def test_secret_list(self):
+        secretroles = (
+            SecretRole(name='Secret Role 1', slug='secret-role-1'),
+            SecretRole(name='Secret Role 2', slug='secret-role-2'),
+        )
+        SecretRole.objects.bulk_create(secretroles)
+
+        # Create one secret per device to allow bulk-editing of names (which must be unique per device/role)
+        Secret.objects.bulk_create((
+            Secret(device=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'),
+            Secret(device=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'),
+            Secret(device=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'),
+        ))
+
+        cls.form_data = {
+            'device': devices[1].pk,
+            'role': secretroles[1].pk,
+            'name': 'Secret X',
+        }
 
-        url = reverse('secrets:secret_list')
-        params = {
-            "role": SecretRole.objects.first().slug,
+        cls.bulk_edit_data = {
+            'role': secretroles[1].pk,
+            'name': 'New name',
         }
 
-        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
-        self.assertEqual(response.status_code, 200)
+    def setUp(self):
 
-    def test_secret(self):
+        super().setUp()
 
-        secret = Secret.objects.first()
-        response = self.client.get(secret.get_absolute_url(), follow=True)
-        self.assertEqual(response.status_code, 200)
+        # Set up a master key for the test user
+        userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
+        userkey.save()
+        master_key = userkey.get_master_key(PRIVATE_KEY)
+        self.session_key = SessionKey(userkey=userkey)
+        self.session_key.save(master_key)
 
-    def test_secret_import(self):
+    def test_import_objects(self):
+        self.add_permissions('secrets.add_secret')
 
         csv_data = (
             "device,role,name,plaintext",
@@ -125,5 +116,5 @@ class SecretTestCase(TestCase):
 
         response = self.client.post(reverse('secrets:secret_import'), {'csv': '\n'.join(csv_data)})
 
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)
         self.assertEqual(Secret.objects.count(), 6)

+ 14 - 14
netbox/secrets/urls.py

@@ -8,21 +8,21 @@ app_name = 'secrets'
 urlpatterns = [
 
     # 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
-    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}),
 
 ]

+ 0 - 1
netbox/templates/circuits/circuit_list.html

@@ -16,7 +16,6 @@
     </div>
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 0 - 1
netbox/templates/circuits/provider_list.html

@@ -16,7 +16,6 @@
     </div>
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

@@ -32,7 +32,7 @@
                             {% if cable.label %}<code>{{ cable.label }}</code>{% else %}Cable #{{ cable.pk }}{% endif %}
                         </a>
                     </h4>
-                    <p><span class="label label-{% if cable.status %}success{% else %}info{% endif %}">{{ cable.get_status_display }}</span></p>
+                    <p><span class="label label-{{ cable.get_status_class }}">{{ cable.get_status_display }}</span></p>
                     <p>{{ cable.get_type_display|default:"" }}</p>
                     {% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %}
                     {% if cable.color %}

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

@@ -1,20 +1,17 @@
 {% extends '_base.html' %}
 {% load buttons %}
-{% load helpers %}
 
 {% block content %}
 <div class="pull-right noprint">
     {% export_button content_type %}
 </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="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 class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-        {% include 'inc/tags_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 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>
                 </button>
                 <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>
             </div>
         {% endif %}
@@ -333,12 +349,12 @@
                     {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
                         <div class="panel-footer text-right noprint">
                             {% 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
                                 </a>
                             {% endif %}
                             {% 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
                                 </a>
                             {% endif %}
@@ -524,13 +540,13 @@
                             </button>
                         {% endif %}
                         {% 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
                             </button>
                         {% endif %}
                         {% if perms.dcim.add_devicebay %}
                             <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
                                 </a>
                             </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">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </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
                             </button>
                         {% endif %}
@@ -597,13 +613,13 @@
                             </button>
                         {% endif %}
                         {% 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
                             </button>
                         {% endif %}
                         {% if perms.dcim.add_interface %}
                             <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
                                 </a>
                             </div>
@@ -619,6 +635,7 @@
                 {% if perms.dcim.delete_consoleserverport %}
                     <form method="post">
                     {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                 {% endif %}
                 <div class="panel panel-default">
                     <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">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </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
                             </button>
                             <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>
                         {% endif %}
                         {% 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
                             </button>
                         {% endif %}
                         {% if perms.dcim.add_consoleserverport %}
                             <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
                                 </a>
                             </div>
@@ -679,6 +696,7 @@
                 {% if perms.dcim.delete_poweroutlet %}
                     <form method="post">
                     {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                 {% endif %}
                 <div class="panel panel-default">
                     <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">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </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
                             </button>
                             <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>
                         {% endif %}
                         {% 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
                             </button>
                         {% endif %}
                         {% if perms.dcim.add_poweroutlet %}
                             <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
                                 </a>
                             </div>
@@ -738,7 +756,8 @@
             {% endif %}
             {% if front_ports %}
                 <form method="post">
-                {% csrf_token %}
+                    {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                     <div class="panel panel-default">
                         <div class="panel-heading">
                             <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">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 </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
                                 </button>
                                 <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>
                             {% endif %}
                             {% 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
                                 </button>
                             {% endif %}
                             {% if perms.dcim.add_frontport %}
                                 <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
                                     </a>
                                 </div>
@@ -796,7 +815,8 @@
             {% endif %}
             {% if rear_ports %}
                 <form method="post">
-                {% csrf_token %}
+                    {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                     <div class="panel panel-default">
                         <div class="panel-heading">
                             <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">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 </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
                                 </button>
                                 <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>
                             {% endif %}
                             {% 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
                                 </button>
                             {% endif %}
                             {% if perms.dcim.add_rearport %}
                                 <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
                                     </a>
                                 </div>

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

@@ -1,10 +1,10 @@
 {% extends '_base.html' %}
 {% load form_helpers %}
 
-{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
+{% block title %}Create {{ component_type }}{% endblock %}
 
 {% block content %}
-<form action="." method="post" class="form form-horizontal">
+<form action="" method="post" class="form form-horizontal">
     {% csrf_token %}
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
@@ -21,12 +21,6 @@
                     <strong>{{ component_type|title }}</strong>
                 </div>
                 <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 %}
                 </div>
             </div>

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

@@ -54,7 +54,7 @@
                 </table>
                 {% if perms.dcim.add_inventoryitem %}
                     <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
                         </a>
                     </div>

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

@@ -16,7 +16,6 @@
     </div>
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

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

+ 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>
                 </button>
                 <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>
             </div>
         {% endif %}
@@ -136,48 +136,48 @@
 {% if devicetype.consoleport_templates.exists or devicetype.powerport_templates.exists %}
     <div class="row">
         <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 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>
 {% endif %}
 {% if devicetype.is_parent_device or devicebay_table.rows %}
     <div class="row">
         <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>
 {% endif %}
 {% if devicetype.consoleserverport_templates.exists %}
     <div class="row">
         <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>
 {% endif %}
 {% if devicetype.poweroutlet_templates.exists %}
     <div class="row">
         <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>
 {% endif %}
 {% if devicetype.interface_templates.exists %}
     <div class="row">
         <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>
 {% endif %}
 {% if devicetype.frontport_templates.exists or devicetype.rearport_templates.exists %}
     <div class="row">
         <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 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>
 {% endif %}

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

@@ -16,7 +16,6 @@
     </div>
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

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

+ 1 - 1
netbox/templates/dcim/inc/cable_toggle_buttons.html

@@ -1,5 +1,5 @@
 {% if perms.dcim.change_cable %}
-    {% if cable.status %}
+    {% if cable.status == 'connected' %}
         <a href="#" class="btn btn-warning btn-xs cable-toggle connected" title="Mark planned" data="{{ cable.pk }}">
             <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
         </a>

+ 1 - 1
netbox/templates/dcim/inc/consoleport.html

@@ -1,4 +1,4 @@
-<tr class="consoleport{% if cp.cable.status %} success{% elif cp.cable %} info{% endif %}">
+<tr class="consoleport{% if cp.cable %} {{ cp.cable.get_status_class }}{% endif %}">
 
     {# Name #}
     <td>

+ 1 - 1
netbox/templates/dcim/inc/consoleserverport.html

@@ -1,6 +1,6 @@
 {% load helpers %}
 
-<tr class="consoleserverport{% if csp.cable.status %} success{% elif csp.cable %} info{% endif %}">
+<tr class="consoleserverport{% if csp.cable %} {{ csp.cable.get_status_class }}{% endif %}">
 
     {# Checkbox #}
     {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}

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

@@ -9,18 +9,18 @@
             <div class="panel-footer noprint">
                 {% if table.rows %}
                     {% 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
                         </button>
                     {% endif %}
                     {% 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
                         </button>
                     {% endif %}
                 {% endif %}
                 <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>
                         Add {{ title }}
                     </a>

+ 1 - 1
netbox/templates/dcim/inc/frontport.html

@@ -1,5 +1,5 @@
 {% load helpers %}
-<tr class="frontport{% if frontport.cable.status %} success{% elif frontport.cable %} info{% endif %}">
+<tr class="frontport{% if frontport.cable %} {{ frontport.cable.get_status_class }}{% endif %}">
 
     {# Checkbox #}
     {% if perms.dcim.change_frontport or perms.dcim.delete_frontport %}

+ 1 - 1
netbox/templates/dcim/inc/interface.html

@@ -1,5 +1,5 @@
 {% load helpers %}
-<tr class="interface{% if not iface.enabled %} danger{% elif iface.cable.status %} success{% elif iface.cable %} info{% elif iface.is_virtual %} warning{% endif %}" id="interface_{{ iface.name }}">
+<tr class="interface{% if not iface.enabled %} danger{% elif iface.cable %} {{ iface.cable.get_status_class }}{% elif iface.is_virtual %} warning{% endif %}" id="interface_{{ iface.name }}">
 
     {# Checkbox #}
     {% if perms.dcim.change_interface or perms.dcim.delete_interface %}

+ 1 - 1
netbox/templates/dcim/inc/poweroutlet.html

@@ -1,6 +1,6 @@
 {% load helpers %}
 
-<tr class="poweroutlet{% if po.cable.status %} success{% elif po.cable %} info{% endif %}">
+<tr class="poweroutlet{% if po.cable %} {{ po.cable.get_status_class }}{% endif %}">
 
     {# Checkbox #}
     {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}

+ 1 - 1
netbox/templates/dcim/inc/powerport.html

@@ -1,4 +1,4 @@
-<tr class="powerport{% if pp.cable.status %} success{% elif pp.cable %} info{% endif %}">
+<tr class="powerport{% if pp.cable %} {{ pp.cable.get_status_class }}{% endif %}">
 
     {# Name #}
     <td>

+ 1 - 1
netbox/templates/dcim/inc/rearport.html

@@ -1,5 +1,5 @@
 {% load helpers %}
-<tr class="rearport{% if rearport.cable.status %} success{% elif rearport.cable %} info{% endif %}">
+<tr class="rearport{% if rearport.cable %} {{ rearport.cable.get_status_class }}{% endif %}">
 
     {# Checkbox #}
     {% if perms.dcim.change_rearport or perms.dcim.delete_rearport %}

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

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

@@ -16,7 +16,6 @@
     </div>
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_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 %}

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

@@ -16,7 +16,6 @@
     </div>
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_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 %}

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

@@ -16,7 +16,6 @@
     </div>
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
     </div>
 </div>
 {% endblock %}

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

@@ -13,7 +13,6 @@
     </div>
     <div class="col-md-3 noprint">
 		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
     </div>
 </div>
 {% endblock %}

+ 35 - 1
netbox/templates/extras/configcontext.html

@@ -1,5 +1,6 @@
 {% extends '_base.html' %}
 {% load helpers %}
+{% load static %}
 
 {% block header %}
     <div class="row noprint">
@@ -134,6 +135,34 @@
                             {% endif %}
                         </td>
                     </tr>
+                    <tr>
+                        <td>Cluster Groups</td>
+                        <td>
+                            {% if configcontext.cluster_groups.all %}
+                                <ul>
+                                    {% for cluster_group in configcontext.cluster_groups.all %}
+                                        <li><a href="{{ cluster_group.get_absolute_url }}">{{ cluster_group }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Clusters</td>
+                        <td>
+                            {% if configcontext.clusters.all %}
+                                <ul>
+                                    {% for cluster in configcontext.clusters.all %}
+                                        <li><a href="{{ cluster.get_absolute_url }}">{{ cluster }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
                     <tr>
                         <td>Tenant Groups</td>
                         <td>
@@ -183,11 +212,16 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Data</strong>
+                    {% include 'extras/inc/configcontext_format.html' %}
                 </div>
                 <div class="panel-body">
-                    <pre>{{ configcontext.data|render_json }}</pre>
+                    {% include 'extras/inc/configcontext_data.html' with data=configcontext.data %}
                 </div>
             </div>
         </div>
     </div>
 {% endblock %}
+
+{% block javascript %}
+<script src="{% static 'js/configcontext.js' %}?v{{ settings.VERSION }}"></script>
+{% endblock %}

+ 2 - 0
netbox/templates/extras/configcontext_edit.html

@@ -18,6 +18,8 @@
             {% render_field form.sites %}
             {% render_field form.roles %}
             {% render_field form.platforms %}
+            {% render_field form.cluster_groups %}
+            {% render_field form.clusters %}
             {% render_field form.tenant_groups %}
             {% render_field form.tenants %}
             {% render_field form.tags %}

+ 8 - 0
netbox/templates/extras/inc/configcontext_data.html

@@ -0,0 +1,8 @@
+{% load helpers %}
+
+<div class="rendered-context-data" data-format="json">
+    <pre>{{ data|render_json }}</pre>
+</div>
+<div class="rendered-context-data" data-format="yaml" style="display: none;">
+    <pre>{{ data|render_yaml }}</pre>
+</div>

+ 6 - 0
netbox/templates/extras/inc/configcontext_format.html

@@ -0,0 +1,6 @@
+<div class="pull-right">
+    <div class="btn-group btn-group-xs" role="group">
+        <span class="btn btn-default rendered-context-format active" data-format="json">JSON</span>
+        <span class="btn btn-default rendered-context-format" data-format="yaml">YAML</span>
+    </div>
+</div>

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