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

Merge branch 'develop' into 3995-navbar-overflow

hSaria 6 лет назад
Родитель
Сommit
6ac8d41323
100 измененных файлов с 3078 добавлено и 7897 удалено
  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. 54 2
      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. 26 2
      netbox/dcim/constants.py
  18. 0 5732
      netbox/dcim/fixtures/dcim.json
  19. 381 80
      netbox/dcim/forms.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. 20 13
      netbox/dcim/models/__init__.py
  23. 2 2
      netbox/dcim/models/device_components.py
  24. 2 1
      netbox/dcim/tables.py
  25. 80 42
      netbox/dcim/tests/test_forms.py
  26. 653 231
      netbox/dcim/tests/test_views.py
  27. 259 248
      netbox/dcim/urls.py
  28. 68 65
      netbox/dcim/views.py
  29. 15 1
      netbox/extras/api/serializers.py
  30. 10 10
      netbox/extras/api/urls.py
  31. 17 0
      netbox/extras/filters.py
  32. 0 35
      netbox/extras/fixtures/extras.json
  33. 75 103
      netbox/extras/forms.py
  34. 24 0
      netbox/extras/migrations/0037_configcontexts_clusters.py
  35. 81 0
      netbox/extras/models.py
  36. 6 0
      netbox/extras/querysets.py
  37. 5 4
      netbox/extras/scripts.py
  38. 113 2
      netbox/extras/tests/test_customfields.py
  39. 30 0
      netbox/extras/tests/test_filters.py
  40. 58 42
      netbox/extras/tests/test_views.py
  41. 23 23
      netbox/extras/urls.py
  42. 3 3
      netbox/extras/views.py
  43. 2 1
      netbox/extras/webhooks.py
  44. 3 2
      netbox/ipam/api/serializers.py
  45. 10 10
      netbox/ipam/api/urls.py
  46. 43 1
      netbox/ipam/constants.py
  47. 0 329
      netbox/ipam/fixtures/ipam.json
  48. 51 31
      netbox/ipam/forms.py
  49. 1 1
      netbox/ipam/migrations/0029_3569_ipaddress_fields.py
  50. 21 0
      netbox/ipam/migrations/0034_fix_ipaddress_status_dhcp.py
  51. 3 3
      netbox/ipam/models.py
  52. 3 0
      netbox/ipam/tests/test_api.py
  53. 226 276
      netbox/ipam/tests/test_views.py
  54. 76 76
      netbox/ipam/urls.py
  55. 7 9
      netbox/ipam/views.py
  56. 3 1
      netbox/netbox/settings.py
  57. 0 0
      netbox/netbox/tests/__init__.py
  58. 13 0
      netbox/netbox/tests/test_api.py
  59. 24 0
      netbox/netbox/tests/test_views.py
  60. 36 34
      netbox/netbox/urls.py
  61. 1 1
      netbox/netbox/views.py
  62. 11 0
      netbox/project-static/js/configcontext.js
  63. 7 4
      netbox/project-static/js/forms.js
  64. 9 4
      netbox/project-static/js/interface_toggles.js
  65. 5 5
      netbox/secrets/api/urls.py
  66. 5 0
      netbox/secrets/constants.py
  67. 10 6
      netbox/secrets/forms.py
  68. 68 77
      netbox/secrets/tests/test_views.py
  69. 14 14
      netbox/secrets/urls.py
  70. 0 1
      netbox/templates/circuits/circuit_list.html
  71. 0 1
      netbox/templates/circuits/provider_list.html
  72. 1 1
      netbox/templates/dcim/cable_trace.html
  73. 49 29
      netbox/templates/dcim/device.html
  74. 2 8
      netbox/templates/dcim/device_component_add.html
  75. 0 1
      netbox/templates/dcim/device_component_list.html
  76. 1 1
      netbox/templates/dcim/device_inventory.html
  77. 0 1
      netbox/templates/dcim/device_list.html
  78. 16 16
      netbox/templates/dcim/devicetype.html
  79. 0 1
      netbox/templates/dcim/devicetype_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. 0 1
      netbox/templates/dcim/powerfeed_list.html
  90. 3 7
      netbox/templates/dcim/rack_elevation_list.html
  91. 0 1
      netbox/templates/dcim/rack_list.html
  92. 0 1
      netbox/templates/dcim/site_list.html
  93. 0 1
      netbox/templates/dcim/virtualchassis_list.html
  94. 35 1
      netbox/templates/extras/configcontext.html
  95. 2 0
      netbox/templates/extras/configcontext_edit.html
  96. 8 0
      netbox/templates/extras/inc/configcontext_data.html
  97. 6 0
      netbox/templates/extras/inc/configcontext_format.html
  98. 9 3
      netbox/templates/extras/object_configcontext.html
  99. 4 4
      netbox/templates/inc/custom_fields_panel.html
  100. 0 13
      netbox/templates/inc/tags_panel.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.

+ 54 - 2
docs/release-notes/version-2.7.md

@@ -1,15 +1,67 @@
-# v2.7.3 (FUTURE)
+# v2.7.5 (FUTURE)
+
+## Enhancements
+
+* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable
+* [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components
+
+## 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
 
+* [#3950](https://github.com/netbox-community/netbox/issues/3950) - Automatically select parent manufacturer when specifying initial device type during device creation
+* [#3982](https://github.com/netbox-community/netbox/issues/3982) - Restore tooltip for reservations on rack elevations
 * [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices
 * [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks
-* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Fixed overflowing dropdown menus becoming unreachable
+* [#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

+ 26 - 2
netbox/dcim/constants.py

@@ -4,17 +4,30 @@ from .choices import InterfaceTypeChoices
 
 
 #
-# Rack elevation rendering
+# Racks
 #
 
+RACK_U_HEIGHT_DEFAULT = 42
+
 RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
 
 
 #
-# Interface type groups
+# RearPorts
 #
 
+REARPORT_POSITIONS_MIN = 1
+REARPORT_POSITIONS_MAX = 64
+
+
+#
+# Interfaces
+#
+
+INTERFACE_MTU_MIN = 1
+INTERFACE_MTU_MAX = 32767  # Max value of a signed 16-bit integer
+
 VIRTUAL_IFACE_TYPES = [
     InterfaceTypeChoices.TYPE_VIRTUAL,
     InterfaceTypeChoices.TYPE_LAG,
@@ -31,6 +44,17 @@ WIRELESS_IFACE_TYPES = [
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 
 
+#
+# PowerFeeds
+#
+
+POWERFEED_VOLTAGE_DEFAULT = 120
+
+POWERFEED_AMPERAGE_DEFAULT = 20
+
+POWERFEED_MAX_UTILIZATION_DEFAULT = 80  # Percentage
+
+
 #
 # Cabling and connections
 #

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

@@ -1,5732 +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.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.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.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.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": ""
-    }
-}
-]

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


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

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

@@ -405,7 +405,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 ''
@@ -414,7 +414,7 @@ class RackElevationHelperMixin:
         drawing.add(drawing.text(str(device), insert=text))
 
     @staticmethod
-    def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_):
+    def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
         link = drawing.add(
             drawing.a(
                 href='{}?{}'.format(
@@ -424,6 +424,10 @@ class RackElevationHelperMixin:
                 target='_top'
             )
         )
+        if reservation:
+            link.set_desc('{} — {} · {}'.format(
+                reservation.description, reservation.user, reservation.created
+            ))
         link.add(drawing.rect(start, end, class_=class_))
         link.add(drawing.text("add device", insert=text, class_='add-device'))
 
@@ -453,12 +457,13 @@ class RackElevationHelperMixin:
             else:
                 # Draw shallow devices, reservations, or empty units
                 class_ = 'slot'
+                reservation = reserved_units.get(unit["id"])
                 if device:
                     class_ += ' occupied'
-                if unit["id"] in reserved_units:
+                if reservation:
                     class_ += ' reserved'
                 self._draw_empty(
-                    drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_
+                    drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation
                 )
 
             unit_cursor += height
@@ -483,7 +488,12 @@ class RackElevationHelperMixin:
 
         return elevation
 
-    def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_width=230, unit_height=20):
+    def get_elevation_svg(
+            self,
+            face=DeviceFaceChoices.FACE_FRONT,
+            unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
+            unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
+    ):
         """
         Return an SVG of the rack elevation
 
@@ -493,7 +503,7 @@ class RackElevationHelperMixin:
             height of the elevation
         """
         elevation = self.merge_elevations(face)
-        reserved_units = self.get_reserved_units().keys()
+        reserved_units = self.get_reserved_units()
 
         return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height)
 
@@ -569,7 +579,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
         help_text='Rail-to-rail width'
     )
     u_height = models.PositiveSmallIntegerField(
-        default=42,
+        default=RACK_U_HEIGHT_DEFAULT,
         verbose_name='Height (U)',
         validators=[MinValueValidator(1), MaxValueValidator(100)]
     )
@@ -1008,9 +1018,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',
     ]
@@ -1859,15 +1866,15 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
     )
     voltage = models.PositiveSmallIntegerField(
         validators=[MinValueValidator(1)],
-        default=120
+        default=POWERFEED_VOLTAGE_DEFAULT
     )
     amperage = models.PositiveSmallIntegerField(
         validators=[MinValueValidator(1)],
-        default=20
+        default=POWERFEED_AMPERAGE_DEFAULT
     )
     max_utilization = models.PositiveSmallIntegerField(
         validators=[MinValueValidator(1), MaxValueValidator(100)],
-        default=80,
+        default=POWERFEED_MAX_UTILIZATION_DEFAULT,
         help_text="Maximum permissible draw (percentage)"
     )
     available_power = models.PositiveIntegerField(

+ 2 - 2
netbox/dcim/models/device_components.py

@@ -676,7 +676,7 @@ class Interface(CableTermination, ComponentModel):
             self.untagged_vlan = None
 
         # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
-        if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED:
+        if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
             self.tagged_vlans.clear()
 
         return super().save(*args, **kwargs)
@@ -1004,7 +1004,7 @@ class InventoryItem(ComponentModel):
         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 (

+ 2 - 1
netbox/dcim/tables.py

@@ -440,7 +440,7 @@ class ConsoleServerPortTemplateTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = ConsoleServerPortTemplate
-        fields = ('pk', 'name', 'actions')
+        fields = ('pk', 'name', 'type', 'actions')
         empty_text = "None"
 
 
@@ -777,6 +777,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')

+ 80 - 42
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,71 +11,108 @@ def get_id(model, slug):
 
 class DeviceTestCase(TestCase):
 
-    fixtures = ['dcim', 'ipam']
+    @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_initial_data_population(self):
+        device_type = DeviceType.objects.first()
+        cluster = Cluster.objects.first()
+        test = DeviceForm(initial={
+            'device_type': device_type.pk,
+            'device_role': DeviceRole.objects.first().pk,
+            'status': DeviceStatusChoices.STATUS_ACTIVE,
+            'site': Site.objects.first().pk,
+            'cluster': cluster.pk,
+        })
+
+        # 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


+ 259 - 248
netbox/dcim/urls.py

@@ -14,317 +14,328 @@ 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'),
+    # TODO: Bulk edit, 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'),
+    # TODO: Bulk edit, 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}),
 
 ]

+ 68 - 65
netbox/dcim/views.py

@@ -30,6 +30,7 @@ from utilities.views import (
 )
 from virtualization.models import VirtualMachine
 from . import filters, forms, tables
+from .choices import DeviceFaceChoices
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -376,16 +377,15 @@ class RackElevationListView(PermissionRequiredMixin, View):
             page = paginator.page(paginator.num_pages)
 
         # Determine rack face
-        if request.GET.get('face') == '1':
-            face_id = 1
-        else:
-            face_id = 0
+        rack_face = request.GET.get('face', DeviceFaceChoices.FACE_FRONT)
+        if rack_face not in DeviceFaceChoices.values():
+            rack_face = DeviceFaceChoices.FACE_FRONT
 
         return render(request, 'dcim/rack_elevation_list.html', {
             'paginator': paginator,
             'page': page,
             'total_count': total_count,
-            'face_id': face_id,
+            'rack_face': rack_face,
             'filter_form': forms.RackElevationFilterForm(request.GET),
         })
 
@@ -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
 
 
@@ -1205,8 +1229,6 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
 
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsolePort
     form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
@@ -1234,8 +1256,8 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
 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'
 
 
 #
@@ -1253,8 +1275,6 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
 
 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 +1302,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 +1321,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'
 
 
 #
@@ -1321,8 +1340,6 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
 
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerport'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerPort
     form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
@@ -1350,8 +1367,8 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 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'
 
 
 #
@@ -1369,8 +1386,6 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
 
 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 +1413,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 +1432,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'
 
 
 #
@@ -1473,8 +1487,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 +1515,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 +1534,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'
 
 
 #
@@ -1542,8 +1553,6 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
 
 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 +1580,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 +1599,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'
 
 
 #
@@ -1610,8 +1618,6 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
 
 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 +1645,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 +1664,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'
 
 
 #
@@ -1680,8 +1685,6 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
 
 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 +1787,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 +2159,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):

+ 2 - 1
netbox/extras/webhooks.py

@@ -3,6 +3,7 @@ import hashlib
 import hmac
 
 from django.contrib.contenttypes.models import ContentType
+from django.utils import timezone
 
 from extras.models import Webhook
 from utilities.api import get_serializer_for_model
@@ -62,7 +63,7 @@ def enqueue_webhooks(instance, user, request_id, action):
                 serializer.data,
                 instance._meta.model_name,
                 action,
-                str(datetime.datetime.now()),
+                str(timezone.now()),
                 user.username,
                 request_id
             )

+ 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

+ 43 - 1
netbox/ipam/constants.py

@@ -4,10 +4,34 @@ from .choices import IPAddressRoleChoices
 BGP_ASN_MIN = 1
 BGP_ASN_MAX = 2**32 - 1
 
+
+#
+# VRFs
+#
+
+# Per RFC 4364 section 4.2, a route distinguisher may be encoded as one of the following:
+#   * Type 0 (16-bit AS number : 32-bit integer)
+#   * Type 1 (32-bit IPv4 address : 16-bit integer)
+#   * Type 2 (32-bit AS number : 16-bit integer)
+# 21 characters are sufficient to convey the longest possible string value (255.255.255.255:65535)
+VRF_RD_MAX_LENGTH = 21
+
+
+#
+# Prefixes
+#
+
+PREFIX_LENGTH_MIN = 1
+PREFIX_LENGTH_MAX = 127  # IPv6
+
+
 #
-# IP addresses
+# IPAddresses
 #
 
+IPADDRESS_MASK_LENGTH_MIN = 1
+IPADDRESS_MASK_LENGTH_MAX = 128  # IPv6
+
 IPADDRESS_ROLES_NONUNIQUE = (
     # IPAddress roles which are exempt from unique address enforcement
     IPAddressRoleChoices.ROLE_ANYCAST,
@@ -17,3 +41,21 @@ IPADDRESS_ROLES_NONUNIQUE = (
     IPAddressRoleChoices.ROLE_GLBP,
     IPAddressRoleChoices.ROLE_CARP,
 )
+
+
+#
+# VLANs
+#
+
+# 12-bit VLAN ID (values 0 and 4095 are reserved)
+VLAN_VID_MIN = 1
+VLAN_VID_MAX = 4094
+
+
+#
+# Services
+#
+
+# 16-bit port number
+SERVICE_PORT_MIN = 1
+SERVICE_PORT_MAX = 65535

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

+ 51 - 31
netbox/ipam/forms.py

@@ -4,33 +4,36 @@ 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 *
 from .choices import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
-IP_FAMILY_CHOICES = [
-    ('', 'All'),
-    (4, 'IPv4'),
-    (6, 'IPv6'),
-]
 
-PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)])
-IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)])
+PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
+    (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
+])
+
+IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
+    (i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1)
+])
 
 
 #
 # VRFs
 #
 
-class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     tags = TagField(
         required=False
     )
@@ -48,7 +51,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
 
 
-class VRFCSVForm(forms.ModelForm):
+class VRFCSVForm(CustomFieldModelCSVForm):
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
@@ -102,6 +105,7 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         required=False,
         label='Search'
     )
+    tag = TagFilterField(model)
 
 
 #
@@ -143,7 +147,7 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
 # Aggregates
 #
 
-class AggregateForm(BootstrapMixin, CustomFieldForm):
+class AggregateForm(BootstrapMixin, CustomFieldModelForm):
     tags = TagField(
         required=False
     )
@@ -165,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',
@@ -218,7 +222,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
     )
     family = forms.ChoiceField(
         required=False,
-        choices=IP_FAMILY_CHOICES,
+        choices=add_blank_choice(IPAddressFamilyChoices),
         label='Address family',
         widget=StaticSelect2()
     )
@@ -231,6 +235,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
             value_field="slug",
         )
     )
+    tag = TagFilterField(model)
 
 
 #
@@ -262,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,
@@ -340,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',
@@ -450,8 +455,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         )
     )
     prefix_length = forms.IntegerField(
-        min_value=1,
-        max_value=127,
+        min_value=PREFIX_LENGTH_MIN,
+        max_value=PREFIX_LENGTH_MAX,
         required=False
     )
     tenant = forms.ModelChoiceField(
@@ -510,7 +515,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
     )
     family = forms.ChoiceField(
         required=False,
-        choices=IP_FAMILY_CHOICES,
+        choices=add_blank_choice(IPAddressFamilyChoices),
         label='Address family',
         widget=StaticSelect2()
     )
@@ -577,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
@@ -634,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=(
@@ -739,7 +756,7 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
     )
 
 
-class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
     class Meta:
         model = IPAddress
@@ -759,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',
@@ -896,8 +913,8 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         )
     )
     mask_length = forms.IntegerField(
-        min_value=1,
-        max_value=128,
+        min_value=IPADDRESS_MASK_LENGTH_MIN,
+        max_value=IPADDRESS_MASK_LENGTH_MAX,
         required=False
     )
     tenant = forms.ModelChoiceField(
@@ -969,7 +986,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
     )
     family = forms.ChoiceField(
         required=False,
-        choices=IP_FAMILY_CHOICES,
+        choices=add_blank_choice(IPAddressFamilyChoices),
         label='Address family',
         widget=StaticSelect2()
     )
@@ -1005,6 +1022,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
     )
+    tag = TagFilterField(model)
 
 
 #
@@ -1075,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,
@@ -1123,7 +1141,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
 
 
-class VLANCSVForm(forms.ModelForm):
+class VLANCSVForm(CustomFieldModelCSVForm):
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         required=False,
@@ -1292,16 +1310,17 @@ 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=1,
-        max_value=65535
+        min_value=SERVICE_PORT_MIN,
+        max_value=SERVICE_PORT_MAX
     )
     tags = TagField(
         required=False
@@ -1352,6 +1371,7 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     port = forms.IntegerField(
         required=False,
     )
+    tag = TagFilterField(model)
 
 
 class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -1378,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 - 3
netbox/ipam/models.py

@@ -14,7 +14,7 @@ from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 from virtualization.models import VirtualMachine
 from .choices import *
-from .constants import IPADDRESS_ROLES_NONUNIQUE
+from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .managers import IPAddressManager
 from .querysets import PrefixQuerySet
@@ -44,7 +44,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         max_length=50
     )
     rd = models.CharField(
-        max_length=21,
+        max_length=VRF_RD_MAX_LENGTH,
         unique=True,
         blank=True,
         null=True,
@@ -1006,7 +1006,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
         choices=ServiceProtocolChoices
     )
     port = models.PositiveIntegerField(
-        validators=[MinValueValidator(1), MaxValueValidator(65535)],
+        validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)],
         verbose_name='Port number'
     )
     ipaddresses = models.ManyToManyField(

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

+ 7 - 9
netbox/ipam/views.py

@@ -15,6 +15,7 @@ from utilities.views import (
 from virtualization.models import VirtualMachine
 from . import filters, forms, tables
 from .choices import *
+from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
@@ -86,23 +87,20 @@ def add_available_vlans(vlan_group, vlans):
     """
     Create fake records for all gaps between used VLANs
     """
-    MIN_VLAN = 1
-    MAX_VLAN = 4094
-
     if not vlans:
-        return [{'vid': MIN_VLAN, 'available': MAX_VLAN - MIN_VLAN + 1}]
+        return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
 
-    prev_vid = MAX_VLAN
+    prev_vid = VLAN_VID_MAX
     new_vlans = []
     for vlan in vlans:
         if vlan.vid - prev_vid > 1:
             new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
         prev_vid = vlan.vid
 
-    if vlans[0].vid > MIN_VLAN:
-        new_vlans.append({'vid': MIN_VLAN, 'available': vlans[0].vid - MIN_VLAN})
-    if prev_vid < MAX_VLAN:
-        new_vlans.append({'vid': prev_vid + 1, 'available': MAX_VLAN - prev_vid})
+    if vlans[0].vid > VLAN_VID_MIN:
+        new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
+    if prev_vid < VLAN_VID_MAX:
+        new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
 
     vlans = list(vlans) + new_vlans
     vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])

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

+ 0 - 0
netbox/netbox/tests/__init__.py


+ 13 - 0
netbox/netbox/tests/test_api.py

@@ -0,0 +1,13 @@
+from django.urls import reverse
+
+from utilities.testing import APITestCase
+
+
+class AppTest(APITestCase):
+
+    def test_root(self):
+
+        url = reverse('api-root')
+        response = self.client.get('{}?format=api'.format(url), **self.header)
+
+        self.assertEqual(response.status_code, 200)

+ 24 - 0
netbox/netbox/tests/test_views.py

@@ -0,0 +1,24 @@
+import urllib.parse
+
+from utilities.testing import TestCase
+from django.urls import reverse
+
+
+class HomeViewTestCase(TestCase):
+
+    def test_home(self):
+
+        url = reverse('home')
+
+        response = self.client.get(url)
+        self.assertHttpStatus(response, 200)
+
+    def test_search(self):
+
+        url = reverse('search')
+        params = {
+            'q': 'foo',
+        }
+
+        response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
+        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();
+    }
+});

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

@@ -158,14 +158,17 @@ $(document).ready(function() {
 
                 filter_for_elements.each(function(index, filter_for_element) {
                     var param_name = $(filter_for_element).attr(attr_name);
+                    var is_required = $(filter_for_element).attr("required");
                     var is_nullable = $(filter_for_element).attr("nullable");
                     var is_visible = $(filter_for_element).is(":visible");
                     var value = $(filter_for_element).val();
 
-                    if (param_name && is_visible && value) {
-                        parameters[param_name] = value;
-                    } else if (param_name && is_visible && is_nullable) {
-                        parameters[param_name] = "null";
+                    if (param_name && is_visible) {
+                        if (value) {
+                            parameters[param_name] = value;
+                        } else if (is_required && is_nullable) {
+                            parameters[param_name] = "null";
+                        }
                     }
                 });
 

+ 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

+ 5 - 0
netbox/secrets/constants.py

@@ -0,0 +1,5 @@
+#
+# Secrets
+#
+
+SECRET_PLAINTEXT_MAX_LENGTH = 65535

+ 10 - 6
netbox/secrets/forms.py

@@ -4,11 +4,14 @@ 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
 
 
@@ -67,9 +70,9 @@ class SecretRoleCSVForm(forms.ModelForm):
 # Secrets
 #
 
-class SecretForm(BootstrapMixin, CustomFieldForm):
+class SecretForm(BootstrapMixin, CustomFieldModelForm):
     plaintext = forms.CharField(
-        max_length=65535,
+        max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
         label='Plaintext',
         widget=forms.PasswordInput(
@@ -79,7 +82,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
         )
     )
     plaintext2 = forms.CharField(
-        max_length=65535,
+        max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
         label='Plaintext (verify)',
         widget=forms.PasswordInput()
@@ -115,7 +118,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
             })
 
 
-class SecretCSVForm(forms.ModelForm):
+class SecretCSVForm(CustomFieldModelCSVForm):
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         to_field_name='name',
@@ -186,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 %}

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

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

@@ -14,7 +14,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/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 %}

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

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

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

+ 3 - 7
netbox/templates/dcim/rack_elevation_list.html

@@ -3,8 +3,8 @@
 
 {% block content %}
 <div class="btn-group pull-right noprint" role="group">
-    <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=0 %}" class="btn btn-default{% if request.GET.face != '1' %} active{% endif %}">Front</a>
-    <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=1 %}" class="btn btn-default{% if request.GET.face == '1' %} active{% endif %}">Rear</a>
+    <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
+    <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
 </div>
 <h1>{% block title %}Rack Elevations{% endblock %}</h1>
 <div class="row">
@@ -17,11 +17,7 @@
                             <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>
                             <p><small class="text-muted">{{ rack.facility_id|truncatechars:"30" }}</small></p>
                         </div>
-                        {% if face_id %}
-                            {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
-                        {% else %}
-                            {% include 'dcim/inc/rack_elevation.html' with face='front' %}
-                        {% endif %}
+                        {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
                         <div class="clearfix"></div>
                         <div class="rack_header">
                             <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>

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

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

+ 9 - 3
netbox/templates/extras/object_configcontext.html

@@ -1,5 +1,6 @@
 {% extends base_template %}
 {% load helpers %}
+{% load static %}
 
 {% block title %}{{ block.super }} - Config Context{% endblock %}
 
@@ -9,9 +10,10 @@
             <div class="panel panel-default">
                 <div class="panel-heading">
                     <strong>Rendered Context</strong>
+                    {% include 'extras/inc/configcontext_format.html' %}
                 </div>
                 <div class="panel-body">
-                    <pre>{{ rendered_context|render_json }}</pre>
+                    {% include 'extras/inc/configcontext_data.html' with data=rendered_context %}
                 </div>
             </div>
         </div>
@@ -22,7 +24,7 @@
                 </div>
                 <div class="panel-body">
                     {% if obj.local_context_data %}
-                        <pre>{{ obj.local_context_data|render_json }}</pre>
+                        {% include 'extras/inc/configcontext_data.html' with data=obj.local_context_data %}
                     {% else %}
                         <span class="text-muted">None</span>
                     {% endif %}
@@ -47,7 +49,7 @@
                         {% if context.description %}
                             <br /><small>{{ context.description }}</small>
                         {% endif %}
-                        <pre>{{ context.data|render_json }}</pre>
+                        {% include 'extras/inc/configcontext_data.html' with data=context.data %}
                     </div>
                 {% empty %}
                     <div class="panel-body">
@@ -58,3 +60,7 @@
         </div>
     </div>
 {% endblock %}
+
+{% block javascript %}
+<script src="{% static 'js/configcontext.js' %}?v{{ settings.VERSION }}"></script>
+{% endblock %}

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

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

+ 0 - 13
netbox/templates/inc/tags_panel.html

@@ -1,13 +0,0 @@
-{% load helpers %}
-
-<div class="panel panel-default">
-    <div class="panel-heading">
-        <span class="fa fa-tags" aria-hidden="true"></span>
-        <strong>Tags</strong>
-    </div>
-    <div class="panel-body text-center">
-        {% for tag in tags %}
-            <a href="{% querystring request tag=tag.slug %}" class="btn btn-sm {% if tag.slug in request.GET.tag %}btn-primary{% else %}btn-link{% endif %}">{{ tag }} <span class="badge">{{ tag.count }}</span></a>
-        {% endfor %}
-    </div>
-</div>

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