Ver Fonte

Merge branch 'develop' into develop-2.8

Jeremy Stretch há 6 anos atrás
pai
commit
926b1fadf2
100 ficheiros alterados com 4115 adições e 2549 exclusões
  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. 3 0
      .github/stale.yml
  6. 4 0
      base_requirements.txt
  7. 3 2
      docs/additional-features/custom-scripts.md
  8. 23 1
      docs/configuration/optional-settings.md
  9. 48 3
      docs/configuration/required-settings.md
  10. 10 2
      docs/development/style-guide.md
  11. 2 1
      docs/installation/3-http-daemon.md
  12. 1 1
      docs/installation/index.md
  13. 28 71
      docs/installation/migrating-to-systemd.md
  14. 1 1
      docs/installation/upgrading.md
  15. 73 1
      docs/release-notes/version-2.7.md
  16. 0 1
      mkdocs.yml
  17. 5 5
      netbox/circuits/api/urls.py
  18. 37 22
      netbox/circuits/forms.py
  19. 73 97
      netbox/circuits/tests/test_views.py
  20. 30 30
      netbox/circuits/urls.py
  21. 0 3
      netbox/circuits/views.py
  22. 18 10
      netbox/dcim/api/serializers.py
  23. 37 37
      netbox/dcim/api/urls.py
  24. 2 0
      netbox/dcim/constants.py
  25. 327 136
      netbox/dcim/forms.py
  26. 1 55
      netbox/dcim/managers.py
  27. 1 1
      netbox/dcim/migrations/0079_3569_rack_fields.py
  28. 27 0
      netbox/dcim/migrations/0092_fix_rack_outer_unit.py
  29. 147 0
      netbox/dcim/migrations/0093_device_component_ordering.py
  30. 138 0
      netbox/dcim/migrations/0094_device_component_template_ordering.py
  31. 70 0
      netbox/dcim/migrations/0095_primary_model_ordering.py
  32. 53 0
      netbox/dcim/migrations/0096_interface_ordering.py
  33. 53 34
      netbox/dcim/models/__init__.py
  34. 64 37
      netbox/dcim/models/device_component_templates.py
  35. 74 43
      netbox/dcim/models/device_components.py
  36. 25 4
      netbox/dcim/tables.py
  37. 645 339
      netbox/dcim/tests/test_views.py
  38. 269 248
      netbox/dcim/urls.py
  39. 158 84
      netbox/dcim/views.py
  40. 15 1
      netbox/extras/api/serializers.py
  41. 10 10
      netbox/extras/api/urls.py
  42. 0 20
      netbox/extras/apps.py
  43. 17 0
      netbox/extras/filters.py
  44. 148 135
      netbox/extras/forms.py
  45. 111 0
      netbox/extras/management/commands/renaturalize.py
  46. 24 0
      netbox/extras/migrations/0037_configcontexts_clusters.py
  47. 81 0
      netbox/extras/models.py
  48. 6 0
      netbox/extras/querysets.py
  49. 7 2
      netbox/extras/scripts.py
  50. 113 2
      netbox/extras/tests/test_customfields.py
  51. 30 0
      netbox/extras/tests/test_filters.py
  52. 58 42
      netbox/extras/tests/test_views.py
  53. 23 23
      netbox/extras/urls.py
  54. 6 5
      netbox/extras/views.py
  55. 2 2
      netbox/ipam/api/serializers.py
  56. 10 10
      netbox/ipam/api/urls.py
  57. 10 0
      netbox/ipam/api/views.py
  58. 8 6
      netbox/ipam/filters.py
  59. 119 104
      netbox/ipam/forms.py
  60. 5 6
      netbox/ipam/tests/test_filters.py
  61. 176 0
      netbox/ipam/tests/test_ordering.py
  62. 214 279
      netbox/ipam/tests/test_views.py
  63. 76 76
      netbox/ipam/urls.py
  64. 1 7
      netbox/ipam/views.py
  65. 8 1
      netbox/netbox/configuration.example.py
  66. 51 21
      netbox/netbox/settings.py
  67. 3 3
      netbox/netbox/tests/test_views.py
  68. 27 27
      netbox/netbox/urls.py
  69. 1 1
      netbox/netbox/views.py
  70. 12 0
      netbox/project-static/css/base.css
  71. 9 0
      netbox/project-static/css/rack_elevation.css
  72. 11 0
      netbox/project-static/js/configcontext.js
  73. 16 14
      netbox/project-static/js/forms.js
  74. 5 5
      netbox/secrets/api/urls.py
  75. 5 3
      netbox/secrets/api/views.py
  76. 17 12
      netbox/secrets/forms.py
  77. 36 11
      netbox/secrets/tests/test_api.py
  78. 63 77
      netbox/secrets/tests/test_views.py
  79. 14 14
      netbox/secrets/urls.py
  80. 1 2
      netbox/secrets/views.py
  81. 0 22
      netbox/templates/circuits/circuit_list.html
  82. 0 18
      netbox/templates/circuits/circuittype_list.html
  83. 0 22
      netbox/templates/circuits/provider_list.html
  84. 0 20
      netbox/templates/dcim/cable_list.html
  85. 49 29
      netbox/templates/dcim/device.html
  86. 2 8
      netbox/templates/dcim/device_component_add.html
  87. 0 20
      netbox/templates/dcim/device_component_list.html
  88. 1 1
      netbox/templates/dcim/device_inventory.html
  89. 21 19
      netbox/templates/dcim/device_list.html
  90. 0 18
      netbox/templates/dcim/devicerole_list.html
  91. 16 16
      netbox/templates/dcim/devicetype.html
  92. 0 22
      netbox/templates/dcim/devicetype_list.html
  93. 0 24
      netbox/templates/dcim/inc/device_table.html
  94. 3 3
      netbox/templates/dcim/inc/devicetype_component_table.html
  95. 0 6
      netbox/templates/dcim/inc/rack_elevation.html
  96. 0 21
      netbox/templates/dcim/inventoryitem_list.html
  97. 0 18
      netbox/templates/dcim/manufacturer_list.html
  98. 0 18
      netbox/templates/dcim/platform_list.html
  99. 0 22
      netbox/templates/dcim/powerfeed_list.html
  100. 0 21
      netbox/templates/dcim/powerpanel_list.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
     NetBox installation, or if you have a general question, DO NOT open an
     issue. Instead, post to our mailing list:
     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.
     before submitting a bug report.
 -->
 -->
 ### Environment
 ### 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
     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
     Please indicate the nature of the change by placing an X in one of the
     boxes below.
     boxes below.
 -->
 -->
@@ -14,5 +16,13 @@ about: Suggest an addition or modification to the NetBox documentation
 [ ] Deprecation
 [ ] Deprecation
 [ ] Cleanup (formatting, typos, etc.)
 [ ] Cleanup (formatting, typos, etc.)
 
 
+### Area
+[ ] Installation instructions
+[ ] Configuration parameters
+[ ] Functionality/features
+[ ] REST API
+[ ] Administration/development
+[ ] Other
+
 <!-- Describe the proposed change(s). -->
 <!-- Describe the proposed change(s). -->
 ### Proposed Changes
 ### 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
     If you have a general idea or question, please post to our mailing list
     instead of opening an issue:
     instead of opening an issue:
 
 
@@ -19,8 +21,8 @@ about: Propose a new NetBox feature or enhancement
     before submitting a bug report.
     before submitting a bug report.
 -->
 -->
 ### Environment
 ### 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
     Describe in detail the new functionality you are proposing. Include any

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

@@ -1,14 +1,13 @@
 ---
 ---
 name: 🏡 Housekeeping
 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
 ### Proposed Changes
 
 

+ 3 - 0
.github/stale.yml

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

+ 4 - 0
base_requirements.txt

@@ -22,6 +22,10 @@ django-filter
 # https://github.com/django-mptt/django-mptt
 # https://github.com/django-mptt/django-mptt
 django-mptt
 django-mptt
 
 
+# Context managers for PostgreSQL advisory locks
+# https://github.com/Xof/django-pglocks
+django-pglocks
+
 # Prometheus metrics library for Django
 # Prometheus metrics library for Django
 # https://github.com/korfuri/django-prometheus
 # https://github.com/korfuri/django-prometheus
 django-prometheus
 django-prometheus

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

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

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

+ 48 - 3
docs/configuration/required-settings.md

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

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

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

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

@@ -107,9 +107,10 @@ Install gunicorn:
 # pip3 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
 ```no-highlight
+# cd /opt/netbox
 # cp contrib/gunicorn.py /opt/netbox/gunicorn.py
 # cp contrib/gunicorn.py /opt/netbox/gunicorn.py
 ```
 ```
 
 

+ 1 - 1
docs/installation/index.md

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

+ 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:
 ### 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
 ```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
 ```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.

+ 1 - 1
docs/installation/upgrading.md

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

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

@@ -1,9 +1,81 @@
-# v2.7.4 (FUTURE)
+# v2.7.7 (FUTURE)
+
+## Enhancements
+
+* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment
+* [#4170](https://github.com/netbox-community/netbox/issues/4170) - Improve color contrast in rack elevation drawings
+
+## Bug Fixes
+
+* [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API
+* [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine
+
+---
+
+# v2.7.6 (2020-02-13)
+
+## Bug Fixes
+
+* [#4166](https://github.com/netbox-community/netbox/issues/4166) - Fix schema migrations to enforce maximum character length for naturalized fields
+
+---
+
+# v2.7.5 (2020-02-13)
+
+**Note:** This release includes several database schema migrations that calculate and store copies of names for certain objects to improve natural ordering performance (see [#3799](https://github.com/netbox-community/netbox/issues/3799)). These migrations may take a few minutes to run if you have a very large number of objects defined in NetBox.
+
+## Enhancements
+
+* [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable
+* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components
+* [#3984](https://github.com/netbox-community/netbox/issues/3984) - Add support for Redis Sentinel
+* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevations
+* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add more status choices for virtual machines
+* [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views
+* [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components
+* [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views
+* [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components
+
+## Bug Fixes
+
+* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IP addresses by multiple devices
+* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable on small screens
+* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests
+* [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional
+* [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view
+* [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form
+* [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list
+* [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms
+* [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type
+* [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit
+* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Restore device bay counts in rack elevation diagrams
+* [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption
+* [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts
+* [#4159](https://github.com/netbox-community/netbox/issues/4159) - Fix implementation of Redis caching configuration
+
+---
+
+# v2.7.4 (2020-02-04)
+
+## 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
 ## 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
 * [#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
 * [#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
 
 
 ---
 ---
 
 

+ 0 - 1
mkdocs.yml

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

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

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

+ 37 - 22
netbox/circuits/forms.py

@@ -2,12 +2,15 @@ from django import forms
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import Region, Site
 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.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
-    DatePicker, FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
+    APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker,
+    DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
+    StaticSelect2Multiple, TagFilterField,
 )
 )
 from .choices import CircuitStatusChoices
 from .choices import CircuitStatusChoices
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -17,7 +20,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 # Providers
 # Providers
 #
 #
 
 
-class ProviderForm(BootstrapMixin, CustomFieldForm):
+class ProviderForm(BootstrapMixin, CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
@@ -46,7 +49,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm):
         }
         }
 
 
 
 
-class ProviderCSVForm(forms.ModelForm):
+class ProviderCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -105,7 +108,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -117,9 +120,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
@@ -129,6 +133,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='ASN'
         label='ASN'
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 #
 #
@@ -160,7 +165,19 @@ class CircuitTypeCSVForm(forms.ModelForm):
 # Circuits
 # Circuits
 #
 #
 
 
-class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all(),
+        widget=APISelect(
+            api_url="/api/circuits/providers/"
+        )
+    )
+    type = DynamicModelChoiceField(
+        queryset=CircuitType.objects.all(),
+        widget=APISelect(
+            api_url="/api/circuits/circuit-types/"
+        )
+    )
     comments = CommentField()
     comments = CommentField()
     tags = TagField(
     tags = TagField(
         required=False
         required=False
@@ -177,18 +194,12 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             'commit_rate': "Committed rate",
             'commit_rate': "Committed rate",
         }
         }
         widgets = {
         widgets = {
-            'provider': APISelect(
-                api_url="/api/circuits/providers/"
-            ),
-            'type': APISelect(
-                api_url="/api/circuits/circuit-types/"
-            ),
             'status': StaticSelect2(),
             'status': StaticSelect2(),
             'install_date': DatePicker(),
             'install_date': DatePicker(),
         }
         }
 
 
 
 
-class CircuitCSVForm(forms.ModelForm):
+class CircuitCSVForm(CustomFieldModelCSVForm):
     provider = forms.ModelChoiceField(
     provider = forms.ModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -232,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
         queryset=Circuit.objects.all(),
         queryset=Circuit.objects.all(),
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
     )
     )
-    type = forms.ModelChoiceField(
+    type = DynamicModelChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/circuits/circuit-types/"
             api_url="/api/circuits/circuit-types/"
         )
         )
     )
     )
-    provider = forms.ModelChoiceField(
+    provider = DynamicModelChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -252,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
         initial='',
         initial='',
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -287,17 +298,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    type = FilterChoiceField(
+    type = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/circuits/circuit-types/",
             api_url="/api/circuits/circuit-types/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    provider = FilterChoiceField(
+    provider = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         queryset=Provider.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/circuits/providers/",
             api_url="/api/circuits/providers/",
             value_field="slug",
             value_field="slug",
@@ -308,7 +321,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    region = forms.ModelMultipleChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -320,9 +333,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
@@ -333,6 +347,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
         min_value=0,
         min_value=0,
         label='Commit rate (Kbps)'
         label='Commit rate (Kbps)'
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 #
 #

+ 73 - 97
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 circuits.models import Circuit, CircuitType, Provider
-from utilities.testing import create_test_user
+from utilities.testing import ViewTestCases
 
 
 
 
-class ProviderTestCase(TestCase):
+class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    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.objects.bulk_create([
             Provider(name='Provider 1', slug='provider-1', asn=65001),
             Provider(name='Provider 1', slug='provider-1', asn=65001),
@@ -25,48 +17,40 @@ class ProviderTestCase(TestCase):
             Provider(name='Provider 3', slug='provider-3', asn=65003),
             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",
             "name,slug",
             "Provider 4,provider-4",
             "Provider 4,provider-4",
             "Provider 5,provider-5",
             "Provider 5,provider-5",
             "Provider 6,provider-6",
             "Provider 6,provider-6",
         )
         )
 
 
-        response = self.client.post(reverse('circuits:provider_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Provider.objects.count(), 6)
+        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',
+        }
 
 
 
 
-class CircuitTypeTestCase(TestCase):
+class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = CircuitType
 
 
-    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.objects.bulk_create([
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
             CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
@@ -74,79 +58,71 @@ class CircuitTypeTestCase(TestCase):
             CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
             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",
             "name,slug",
             "Circuit Type 4,circuit-type-4",
             "Circuit Type 4,circuit-type-4",
             "Circuit Type 5,circuit-type-5",
             "Circuit Type 5,circuit-type-5",
             "Circuit Type 6,circuit-type-6",
             "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(ViewTestCases.PrimaryObjectViewTestCase):
+    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.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",
             "cid,provider,type",
             "Circuit 4,Provider 1,Circuit Type 1",
             "Circuit 4,Provider 1,Circuit Type 1",
             "Circuit 5,Provider 1,Circuit Type 1",
             "Circuit 5,Provider 1,Circuit Type 1",
             "Circuit 6,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 = [
 urlpatterns = [
 
 
     # Providers
     # Providers
-    path(r'providers/', views.ProviderListView.as_view(), name='provider_list'),
-    path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
-    path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
-    path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
-    path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
-    path(r'providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
-    path(r'providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
-    path(r'providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
-    path(r'providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
+    path('providers/', views.ProviderListView.as_view(), name='provider_list'),
+    path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
+    path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
+    path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
+    path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
+    path('providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
+    path('providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
+    path('providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
+    path('providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
 
 
     # Circuit types
     # Circuit types
-    path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
-    path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
-    path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
-    path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
-    path(r'circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
-    path(r'circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
+    path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
+    path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
+    path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
+    path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
+    path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
+    path('circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
 
 
     # Circuits
     # Circuits
-    path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'),
-    path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
-    path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
-    path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
-    path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
-    path(r'circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
-    path(r'circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
-    path(r'circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
-    path(r'circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
-    path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
+    path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
+    path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
+    path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
+    path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
+    path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
+    path('circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
+    path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
+    path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
+    path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
+    path('circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
 
 
     # Circuit terminations
     # Circuit terminations
 
 
-    path(r'circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
-    path(r'circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
-    path(r'circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
-    path(r'circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
-    path(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
+    path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
+    path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
+    path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
+    path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
+    path('circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
 
 
 ]
 ]

+ 0 - 3
netbox/circuits/views.py

@@ -29,7 +29,6 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ProviderFilterSet
     filterset = filters.ProviderFilterSet
     filterset_form = forms.ProviderFilterForm
     filterset_form = forms.ProviderFilterForm
     table = tables.ProviderDetailTable
     table = tables.ProviderDetailTable
-    template_name = 'circuits/provider_list.html'
 
 
 
 
 class ProviderView(PermissionRequiredMixin, View):
 class ProviderView(PermissionRequiredMixin, View):
@@ -107,7 +106,6 @@ class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'circuits.view_circuittype'
     permission_required = 'circuits.view_circuittype'
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     table = tables.CircuitTypeTable
     table = tables.CircuitTypeTable
-    template_name = 'circuits/circuittype_list.html'
 
 
 
 
 class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
 class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -151,7 +149,6 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.CircuitFilterSet
     filterset = filters.CircuitFilterSet
     filterset_form = forms.CircuitFilterForm
     filterset_form = forms.CircuitFilterForm
     table = tables.CircuitTable
     table = tables.CircuitTable
-    template_name = 'circuits/circuit_list.html'
 
 
 
 
 class CircuitView(PermissionRequiredMixin, View):
 class CircuitView(PermissionRequiredMixin, View):

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

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

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

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

+ 2 - 0
netbox/dcim/constants.py

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

Diff do ficheiro suprimidas por serem muito extensas
+ 327 - 136
netbox/dcim/forms.py


+ 1 - 55
netbox/dcim/managers.py

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

+ 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):
 def rack_outer_unit_to_slug(apps, schema_editor):
     Rack = apps.get_model('dcim', 'Rack')
     Rack = apps.get_model('dcim', 'Rack')
     for id, slug in RACK_DIMENSION_CHOICES:
     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):
 class Migration(migrations.Migration):

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

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

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

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

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

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

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

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

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

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

+ 53 - 34
netbox/dcim/models/__init__.py

@@ -22,8 +22,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
 from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
-from utilities.fields import ColorField
-from utilities.managers import NaturalOrderingManager
+from utilities.fields import ColorField, NaturalOrderingField
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from utilities.utils import foreground_color, to_meters
 from utilities.utils import foreground_color, to_meters
 from .device_component_templates import (
 from .device_component_templates import (
@@ -134,6 +133,11 @@ class Site(ChangeLoggedModel, CustomFieldModel):
         max_length=50,
         max_length=50,
         unique=True
         unique=True
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     slug = models.SlugField(
     slug = models.SlugField(
         unique=True
         unique=True
     )
     )
@@ -215,8 +219,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
@@ -235,7 +237,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     }
     }
 
 
     class Meta:
     class Meta:
-        ordering = ['name']
+        ordering = ('_name',)
 
 
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
@@ -380,13 +382,17 @@ class RackElevationHelperMixin:
 
 
         # add gradients
         # add gradients
         RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff')
         RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff')
-        RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#f0f0f0')
-        RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc7c7')
+        RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#d7d7d7')
+        RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc0c0')
 
 
         return drawing
         return drawing
 
 
     @staticmethod
     @staticmethod
     def _draw_device_front(drawing, device, start, end, text):
     def _draw_device_front(drawing, device, start, end, text):
+        name = str(device)
+        if device.devicebay_count:
+            name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
+
         color = device.device_role.color
         color = device.device_role.color
         link = drawing.add(
         link = drawing.add(
             drawing.a(
             drawing.a(
@@ -401,7 +407,7 @@ class RackElevationHelperMixin:
         ))
         ))
         link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
         link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
         hex_color = '#{}'.format(foreground_color(color))
         hex_color = '#{}'.format(foreground_color(color))
-        link.add(drawing.text(str(device), insert=text, fill=hex_color))
+        link.add(drawing.text(str(name), insert=text, fill=hex_color))
 
 
     @staticmethod
     @staticmethod
     def _draw_device_rear(drawing, device, start, end, text):
     def _draw_device_rear(drawing, device, start, end, text):
@@ -431,11 +437,19 @@ class RackElevationHelperMixin:
         link.add(drawing.rect(start, end, class_=class_))
         link.add(drawing.rect(start, end, class_=class_))
         link.add(drawing.text("add device", insert=text, class_='add-device'))
         link.add(drawing.text("add device", insert=text, class_='add-device'))
 
 
-    def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height):
+    def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width):
 
 
-        drawing = self._setup_drawing(unit_width, unit_height * self.u_height)
+        drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height)
 
 
         unit_cursor = 0
         unit_cursor = 0
+        for ru in range(0, self.u_height):
+            start_y = ru * unit_height
+            position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2)
+            unit = ru + 1 if self.desc_units else self.u_height - ru
+            drawing.add(
+                drawing.text(str(unit), position_coordinates, class_="unit")
+            )
+
         for unit in elevation:
         for unit in elevation:
 
 
             # Loop through all units in the elevation
             # Loop through all units in the elevation
@@ -445,9 +459,9 @@ class RackElevationHelperMixin:
             # Setup drawing coordinates
             # Setup drawing coordinates
             start_y = unit_cursor * unit_height
             start_y = unit_cursor * unit_height
             end_y = unit_height * height
             end_y = unit_height * height
-            start_cordinates = (0, start_y)
-            end_cordinates = (unit_width, end_y)
-            text_cordinates = (unit_width / 2, start_y + end_y / 2)
+            start_cordinates = (legend_width, start_y)
+            end_cordinates = (legend_width + unit_width, end_y)
+            text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2)
 
 
             # Draw the device
             # Draw the device
             if device and device.face == face:
             if device and device.face == face:
@@ -469,7 +483,7 @@ class RackElevationHelperMixin:
             unit_cursor += height
             unit_cursor += height
 
 
         # Wrap the drawing with a border
         # Wrap the drawing with a border
-        drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack'))
+        drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack'))
 
 
         return drawing
         return drawing
 
 
@@ -492,7 +506,8 @@ class RackElevationHelperMixin:
             self,
             self,
             face=DeviceFaceChoices.FACE_FRONT,
             face=DeviceFaceChoices.FACE_FRONT,
             unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
             unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
-            unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
+            unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
+            legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
     ):
     ):
         """
         """
         Return an SVG of the rack elevation
         Return an SVG of the rack elevation
@@ -505,7 +520,7 @@ class RackElevationHelperMixin:
         elevation = self.merge_elevations(face)
         elevation = self.merge_elevations(face)
         reserved_units = self.get_reserved_units()
         reserved_units = self.get_reserved_units()
 
 
-        return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height)
+        return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width)
 
 
 
 
 class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
 class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
@@ -516,6 +531,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True
+    )
     facility_id = models.CharField(
     facility_id = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
@@ -612,8 +632,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
@@ -634,12 +652,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
     }
     }
 
 
     class Meta:
     class Meta:
-        ordering = ('site', 'group', 'name', 'pk')  # (site, group, name) may be non-unique
-        unique_together = [
+        ordering = ('site', 'group', '_name', 'pk')  # (site, group, name) may be non-unique
+        unique_together = (
             # Name and facility_id must be unique *only* within a RackGroup
             # Name and facility_id must be unique *only* within a RackGroup
-            ['group', 'name'],
-            ['group', 'facility_id'],
-        ]
+            ('group', 'name'),
+            ('group', 'facility_id'),
+        )
 
 
     def __str__(self):
     def __str__(self):
         return self.display_name or super().__str__()
         return self.display_name or super().__str__()
@@ -1018,9 +1036,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
 
 
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = [
-        'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
-    ]
     clone_fields = [
     clone_fields = [
         'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
         'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
     ]
     ]
@@ -1316,6 +1331,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    _name = NaturalOrderingField(
+        target_field='name',
+        max_length=100,
+        blank=True,
+        null=True
+    )
     serial = models.CharField(
     serial = models.CharField(
         max_length=50,
         max_length=50,
         blank=True,
         blank=True,
@@ -1410,8 +1431,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     images = GenericRelation(
     images = GenericRelation(
         to='extras.ImageAttachment'
         to='extras.ImageAttachment'
     )
     )
-
-    objects = NaturalOrderingManager()
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
@@ -1433,12 +1452,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     }
     }
 
 
     class Meta:
     class Meta:
-        ordering = ('name', 'pk')  # Name may be NULL
-        unique_together = [
-            ['site', 'tenant', 'name'],  # See validate_unique below
-            ['rack', 'position', 'face'],
-            ['virtual_chassis', 'vc_position'],
-        ]
+        ordering = ('_name', 'pk')  # Name may be null
+        unique_together = (
+            ('site', 'tenant', 'name'),  # See validate_unique below
+            ('rack', 'position', 'face'),
+            ('virtual_chassis', 'vc_position'),
+        )
         permissions = (
         permissions = (
             ('napalm_read', 'Read-only access to devices via NAPALM'),
             ('napalm_read', 'Read-only access to devices via NAPALM'),
             ('napalm_write', 'Read/write access to devices via NAPALM'),
             ('napalm_write', 'Read/write access to devices via NAPALM'),

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

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

+ 74 - 43
netbox/dcim/models/device_components.py

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

+ 25 - 4
netbox/dcim/tables.py

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

Diff do ficheiro suprimidas por serem muito extensas
+ 645 - 339
netbox/dcim/tests/test_views.py


+ 269 - 248
netbox/dcim/urls.py

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

+ 158 - 84
netbox/dcim/views.py

@@ -152,7 +152,6 @@ class RegionListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RegionFilterSet
     filterset = filters.RegionFilterSet
     filterset_form = forms.RegionFilterForm
     filterset_form = forms.RegionFilterForm
     table = tables.RegionTable
     table = tables.RegionTable
-    template_name = 'dcim/region_list.html'
 
 
 
 
 class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
 class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -191,7 +190,6 @@ class SiteListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.SiteFilterSet
     filterset = filters.SiteFilterSet
     filterset_form = forms.SiteFilterForm
     filterset_form = forms.SiteFilterForm
     table = tables.SiteTable
     table = tables.SiteTable
-    template_name = 'dcim/site_list.html'
 
 
 
 
 class SiteView(PermissionRequiredMixin, View):
 class SiteView(PermissionRequiredMixin, View):
@@ -271,7 +269,6 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RackGroupFilterSet
     filterset = filters.RackGroupFilterSet
     filterset_form = forms.RackGroupFilterForm
     filterset_form = forms.RackGroupFilterForm
     table = tables.RackGroupTable
     table = tables.RackGroupTable
-    template_name = 'dcim/rackgroup_list.html'
 
 
 
 
 class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
 class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -308,7 +305,6 @@ class RackRoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_rackrole'
     permission_required = 'dcim.view_rackrole'
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     table = tables.RackRoleTable
     table = tables.RackRoleTable
-    template_name = 'dcim/rackrole_list.html'
 
 
 
 
 class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
 class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -350,7 +346,6 @@ class RackListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RackFilterSet
     filterset = filters.RackFilterSet
     filterset_form = forms.RackFilterForm
     filterset_form = forms.RackFilterForm
     table = tables.RackDetailTable
     table = tables.RackDetailTable
-    template_name = 'dcim/rack_list.html'
 
 
 
 
 class RackElevationListView(PermissionRequiredMixin, View):
 class RackElevationListView(PermissionRequiredMixin, View):
@@ -474,7 +469,7 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RackReservationFilterSet
     filterset = filters.RackReservationFilterSet
     filterset_form = forms.RackReservationFilterForm
     filterset_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
     table = tables.RackReservationTable
-    template_name = 'dcim/rackreservation_list.html'
+    action_buttons = ()
 
 
 
 
 class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
 class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -533,7 +528,6 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
         platform_count=Count('platforms', distinct=True),
         platform_count=Count('platforms', distinct=True),
     )
     )
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
-    template_name = 'dcim/manufacturer_list.html'
 
 
 
 
 class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
 class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -571,7 +565,6 @@ class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.DeviceTypeFilterSet
     filterset = filters.DeviceTypeFilterSet
     filterset_form = forms.DeviceTypeFilterForm
     filterset_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
-    template_name = 'dcim/devicetype_list.html'
 
 
 
 
 class DeviceTypeView(PermissionRequiredMixin, View):
 class DeviceTypeView(PermissionRequiredMixin, View):
@@ -700,13 +693,11 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 
 
 #
 #
-# Device type components
+# Console port templates
 #
 #
 
 
 class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleporttemplate'
     permission_required = 'dcim.add_consoleporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = ConsolePortTemplate
     model = ConsolePortTemplate
     form = forms.ConsolePortTemplateCreateForm
     form = forms.ConsolePortTemplateCreateForm
     model_form = forms.ConsolePortTemplateForm
     model_form = forms.ConsolePortTemplateForm
@@ -719,17 +710,30 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.ConsolePortTemplateForm
     model_form = forms.ConsolePortTemplateForm
 
 
 
 
+class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_consoleporttemplate'
+    model = ConsolePortTemplate
+
+
+class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_consoleporttemplate'
+    queryset = ConsolePortTemplate.objects.all()
+    table = tables.ConsolePortTemplateTable
+    form = forms.ConsolePortTemplateBulkEditForm
+
+
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleporttemplate'
     permission_required = 'dcim.delete_consoleporttemplate'
     queryset = ConsolePortTemplate.objects.all()
     queryset = ConsolePortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.ConsolePortTemplateTable
     table = tables.ConsolePortTemplateTable
 
 
 
 
+#
+# Console server port templates
+#
+
 class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverporttemplate'
     permission_required = 'dcim.add_consoleserverporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
     form = forms.ConsoleServerPortTemplateCreateForm
     form = forms.ConsoleServerPortTemplateCreateForm
     model_form = forms.ConsoleServerPortTemplateForm
     model_form = forms.ConsoleServerPortTemplateForm
@@ -742,17 +746,30 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView)
     model_form = forms.ConsoleServerPortTemplateForm
     model_form = forms.ConsoleServerPortTemplateForm
 
 
 
 
+class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_consoleserverporttemplate'
+    model = ConsoleServerPortTemplate
+
+
+class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_consoleserverporttemplate'
+    queryset = ConsoleServerPortTemplate.objects.all()
+    table = tables.ConsoleServerPortTemplateTable
+    form = forms.ConsoleServerPortTemplateBulkEditForm
+
+
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverporttemplate'
     permission_required = 'dcim.delete_consoleserverporttemplate'
     queryset = ConsoleServerPortTemplate.objects.all()
     queryset = ConsoleServerPortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.ConsoleServerPortTemplateTable
     table = tables.ConsoleServerPortTemplateTable
 
 
 
 
+#
+# Power port templates
+#
+
 class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerporttemplate'
     permission_required = 'dcim.add_powerporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = PowerPortTemplate
     model = PowerPortTemplate
     form = forms.PowerPortTemplateCreateForm
     form = forms.PowerPortTemplateCreateForm
     model_form = forms.PowerPortTemplateForm
     model_form = forms.PowerPortTemplateForm
@@ -765,17 +782,30 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.PowerPortTemplateForm
     model_form = forms.PowerPortTemplateForm
 
 
 
 
+class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_powerporttemplate'
+    model = PowerPortTemplate
+
+
+class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_powerporttemplate'
+    queryset = PowerPortTemplate.objects.all()
+    table = tables.PowerPortTemplateTable
+    form = forms.PowerPortTemplateBulkEditForm
+
+
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerporttemplate'
     permission_required = 'dcim.delete_powerporttemplate'
     queryset = PowerPortTemplate.objects.all()
     queryset = PowerPortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.PowerPortTemplateTable
     table = tables.PowerPortTemplateTable
 
 
 
 
+#
+# Power outlet templates
+#
+
 class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlettemplate'
     permission_required = 'dcim.add_poweroutlettemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = PowerOutletTemplate
     model = PowerOutletTemplate
     form = forms.PowerOutletTemplateCreateForm
     form = forms.PowerOutletTemplateCreateForm
     model_form = forms.PowerOutletTemplateForm
     model_form = forms.PowerOutletTemplateForm
@@ -788,17 +818,30 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.PowerOutletTemplateForm
     model_form = forms.PowerOutletTemplateForm
 
 
 
 
+class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_poweroutlettemplate'
+    model = PowerOutletTemplate
+
+
+class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_poweroutlettemplate'
+    queryset = PowerOutletTemplate.objects.all()
+    table = tables.PowerOutletTemplateTable
+    form = forms.PowerOutletTemplateBulkEditForm
+
+
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlettemplate'
     permission_required = 'dcim.delete_poweroutlettemplate'
     queryset = PowerOutletTemplate.objects.all()
     queryset = PowerOutletTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.PowerOutletTemplateTable
     table = tables.PowerOutletTemplateTable
 
 
 
 
+#
+# Interface templates
+#
+
 class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interfacetemplate'
     permission_required = 'dcim.add_interfacetemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = InterfaceTemplate
     model = InterfaceTemplate
     form = forms.InterfaceTemplateCreateForm
     form = forms.InterfaceTemplateCreateForm
     model_form = forms.InterfaceTemplateForm
     model_form = forms.InterfaceTemplateForm
@@ -811,10 +854,14 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.InterfaceTemplateForm
     model_form = forms.InterfaceTemplateForm
 
 
 
 
+class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_interfacetemplate'
+    model = InterfaceTemplate
+
+
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interfacetemplate'
     permission_required = 'dcim.change_interfacetemplate'
     queryset = InterfaceTemplate.objects.all()
     queryset = InterfaceTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.InterfaceTemplateTable
     table = tables.InterfaceTemplateTable
     form = forms.InterfaceTemplateBulkEditForm
     form = forms.InterfaceTemplateBulkEditForm
 
 
@@ -822,14 +869,15 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interfacetemplate'
     permission_required = 'dcim.delete_interfacetemplate'
     queryset = InterfaceTemplate.objects.all()
     queryset = InterfaceTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.InterfaceTemplateTable
     table = tables.InterfaceTemplateTable
 
 
 
 
+#
+# Front port templates
+#
+
 class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_frontporttemplate'
     permission_required = 'dcim.add_frontporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = FrontPortTemplate
     model = FrontPortTemplate
     form = forms.FrontPortTemplateCreateForm
     form = forms.FrontPortTemplateCreateForm
     model_form = forms.FrontPortTemplateForm
     model_form = forms.FrontPortTemplateForm
@@ -842,17 +890,30 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.FrontPortTemplateForm
     model_form = forms.FrontPortTemplateForm
 
 
 
 
+class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_frontporttemplate'
+    model = FrontPortTemplate
+
+
+class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_frontporttemplate'
+    queryset = FrontPortTemplate.objects.all()
+    table = tables.FrontPortTemplateTable
+    form = forms.FrontPortTemplateBulkEditForm
+
+
 class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_frontporttemplate'
     permission_required = 'dcim.delete_frontporttemplate'
     queryset = FrontPortTemplate.objects.all()
     queryset = FrontPortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.FrontPortTemplateTable
     table = tables.FrontPortTemplateTable
 
 
 
 
+#
+# Rear port templates
+#
+
 class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_rearporttemplate'
     permission_required = 'dcim.add_rearporttemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = RearPortTemplate
     model = RearPortTemplate
     form = forms.RearPortTemplateCreateForm
     form = forms.RearPortTemplateCreateForm
     model_form = forms.RearPortTemplateForm
     model_form = forms.RearPortTemplateForm
@@ -865,17 +926,30 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.RearPortTemplateForm
     model_form = forms.RearPortTemplateForm
 
 
 
 
+class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_rearporttemplate'
+    model = RearPortTemplate
+
+
+class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_rearporttemplate'
+    queryset = RearPortTemplate.objects.all()
+    table = tables.RearPortTemplateTable
+    form = forms.RearPortTemplateBulkEditForm
+
+
 class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rearporttemplate'
     permission_required = 'dcim.delete_rearporttemplate'
     queryset = RearPortTemplate.objects.all()
     queryset = RearPortTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.RearPortTemplateTable
     table = tables.RearPortTemplateTable
 
 
 
 
+#
+# Device bay templates
+#
+
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
 class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebaytemplate'
     permission_required = 'dcim.add_devicebaytemplate'
-    parent_model = DeviceType
-    parent_field = 'device_type'
     model = DeviceBayTemplate
     model = DeviceBayTemplate
     form = forms.DeviceBayTemplateCreateForm
     form = forms.DeviceBayTemplateCreateForm
     model_form = forms.DeviceBayTemplateForm
     model_form = forms.DeviceBayTemplateForm
@@ -888,10 +962,21 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView):
     model_form = forms.DeviceBayTemplateForm
     model_form = forms.DeviceBayTemplateForm
 
 
 
 
+class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_devicebaytemplate'
+    model = DeviceBayTemplate
+
+
+# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+#     permission_required = 'dcim.change_devicebaytemplate'
+#     queryset = DeviceBayTemplate.objects.all()
+#     table = tables.DeviceBayTemplateTable
+#     form = forms.DeviceBayTemplateBulkEditForm
+
+
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebaytemplate'
     permission_required = 'dcim.delete_devicebaytemplate'
     queryset = DeviceBayTemplate.objects.all()
     queryset = DeviceBayTemplate.objects.all()
-    parent_model = DeviceType
     table = tables.DeviceBayTemplateTable
     table = tables.DeviceBayTemplateTable
 
 
 
 
@@ -903,7 +988,6 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_devicerole'
     permission_required = 'dcim.view_devicerole'
     queryset = DeviceRole.objects.all()
     queryset = DeviceRole.objects.all()
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
-    template_name = 'dcim/devicerole_list.html'
 
 
 
 
 class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
 class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -939,7 +1023,6 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_platform'
     permission_required = 'dcim.view_platform'
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
     table = tables.PlatformTable
     table = tables.PlatformTable
-    template_name = 'dcim/platform_list.html'
 
 
 
 
 class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
 class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -1200,13 +1283,11 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsolePortFilterSet
     filterset = filters.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortDetailTable
     table = tables.ConsolePortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleport'
     permission_required = 'dcim.add_consoleport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsolePort
     model = ConsolePort
     form = forms.ConsolePortCreateForm
     form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
     model_form = forms.ConsolePortForm
@@ -1231,11 +1312,18 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
     default_return_url = 'dcim:consoleport_list'
     default_return_url = 'dcim:consoleport_list'
 
 
 
 
+class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_consoleport'
+    queryset = ConsolePort.objects.all()
+    table = tables.ConsolePortTable
+    form = forms.ConsolePortBulkEditForm
+
+
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleport'
     permission_required = 'dcim.delete_consoleport'
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
-    parent_model = Device
     table = tables.ConsolePortTable
     table = tables.ConsolePortTable
+    default_return_url = 'dcim:consoleport_list'
 
 
 
 
 #
 #
@@ -1248,13 +1336,11 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsoleServerPortFilterSet
     filterset = filters.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortDetailTable
     table = tables.ConsoleServerPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverport'
     permission_required = 'dcim.add_consoleserverport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsoleServerPort
     model = ConsoleServerPort
     form = forms.ConsoleServerPortCreateForm
     form = forms.ConsoleServerPortCreateForm
     model_form = forms.ConsoleServerPortForm
     model_form = forms.ConsoleServerPortForm
@@ -1282,7 +1368,6 @@ class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_consoleserverport'
     permission_required = 'dcim.change_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    parent_model = Device
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
     form = forms.ConsoleServerPortBulkEditForm
     form = forms.ConsoleServerPortBulkEditForm
 
 
@@ -1302,8 +1387,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     permission_required = 'dcim.delete_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    parent_model = Device
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
+    default_return_url = 'dcim:consoleserverport_list'
 
 
 
 
 #
 #
@@ -1316,13 +1401,11 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerPortFilterSet
     filterset = filters.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortDetailTable
     table = tables.PowerPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerport'
     permission_required = 'dcim.add_powerport'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerPort
     model = PowerPort
     form = forms.PowerPortCreateForm
     form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
     model_form = forms.PowerPortForm
@@ -1347,11 +1430,18 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
     default_return_url = 'dcim:powerport_list'
     default_return_url = 'dcim:powerport_list'
 
 
 
 
+class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_powerport'
+    queryset = PowerPort.objects.all()
+    table = tables.PowerPortTable
+    form = forms.PowerPortBulkEditForm
+
+
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerport'
     permission_required = 'dcim.delete_powerport'
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
-    parent_model = Device
     table = tables.PowerPortTable
     table = tables.PowerPortTable
+    default_return_url = 'dcim:powerport_list'
 
 
 
 
 #
 #
@@ -1364,13 +1454,11 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerOutletFilterSet
     filterset = filters.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletDetailTable
     table = tables.PowerOutletDetailTable
-    template_name = 'dcim/device_component_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlet'
     permission_required = 'dcim.add_poweroutlet'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerOutlet
     model = PowerOutlet
     form = forms.PowerOutletCreateForm
     form = forms.PowerOutletCreateForm
     model_form = forms.PowerOutletForm
     model_form = forms.PowerOutletForm
@@ -1398,7 +1486,6 @@ class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_poweroutlet'
     permission_required = 'dcim.change_poweroutlet'
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    parent_model = Device
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
     form = forms.PowerOutletBulkEditForm
     form = forms.PowerOutletBulkEditForm
 
 
@@ -1418,8 +1505,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     permission_required = 'dcim.delete_poweroutlet'
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    parent_model = Device
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
+    default_return_url = 'dcim:poweroutlet_list'
 
 
 
 
 #
 #
@@ -1432,7 +1519,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.InterfaceFilterSet
     filterset = filters.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceDetailTable
     table = tables.InterfaceDetailTable
-    template_name = 'dcim/device_component_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class InterfaceView(PermissionRequiredMixin, View):
 class InterfaceView(PermissionRequiredMixin, View):
@@ -1473,8 +1560,6 @@ class InterfaceView(PermissionRequiredMixin, View):
 
 
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interface'
     permission_required = 'dcim.add_interface'
-    parent_model = Device
-    parent_field = 'device'
     model = Interface
     model = Interface
     form = forms.InterfaceCreateForm
     form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
@@ -1503,7 +1588,6 @@ class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    parent_model = Device
     table = tables.InterfaceTable
     table = tables.InterfaceTable
     form = forms.InterfaceBulkEditForm
     form = forms.InterfaceBulkEditForm
 
 
@@ -1523,8 +1607,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    parent_model = Device
     table = tables.InterfaceTable
     table = tables.InterfaceTable
+    default_return_url = 'dcim:interface_list'
 
 
 
 
 #
 #
@@ -1537,13 +1621,11 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.FrontPortFilterSet
     filterset = filters.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortDetailTable
     table = tables.FrontPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_frontport'
     permission_required = 'dcim.add_frontport'
-    parent_model = Device
-    parent_field = 'device'
     model = FrontPort
     model = FrontPort
     form = forms.FrontPortCreateForm
     form = forms.FrontPortCreateForm
     model_form = forms.FrontPortForm
     model_form = forms.FrontPortForm
@@ -1571,7 +1653,6 @@ class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_frontport'
     permission_required = 'dcim.change_frontport'
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    parent_model = Device
     table = tables.FrontPortTable
     table = tables.FrontPortTable
     form = forms.FrontPortBulkEditForm
     form = forms.FrontPortBulkEditForm
 
 
@@ -1591,8 +1672,8 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_frontport'
     permission_required = 'dcim.delete_frontport'
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    parent_model = Device
     table = tables.FrontPortTable
     table = tables.FrontPortTable
+    default_return_url = 'dcim:frontport_list'
 
 
 
 
 #
 #
@@ -1605,13 +1686,11 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RearPortFilterSet
     filterset = filters.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortDetailTable
     table = tables.RearPortDetailTable
-    template_name = 'dcim/device_component_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_rearport'
     permission_required = 'dcim.add_rearport'
-    parent_model = Device
-    parent_field = 'device'
     model = RearPort
     model = RearPort
     form = forms.RearPortCreateForm
     form = forms.RearPortCreateForm
     model_form = forms.RearPortForm
     model_form = forms.RearPortForm
@@ -1639,7 +1718,6 @@ class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rearport'
     permission_required = 'dcim.change_rearport'
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    parent_model = Device
     table = tables.RearPortTable
     table = tables.RearPortTable
     form = forms.RearPortBulkEditForm
     form = forms.RearPortBulkEditForm
 
 
@@ -1659,8 +1737,8 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rearport'
     permission_required = 'dcim.delete_rearport'
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    parent_model = Device
     table = tables.RearPortTable
     table = tables.RearPortTable
+    default_return_url = 'dcim:rearport_list'
 
 
 
 
 #
 #
@@ -1675,13 +1753,11 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.DeviceBayFilterSet
     filterset = filters.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayDetailTable
     table = tables.DeviceBayDetailTable
-    template_name = 'dcim/device_component_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebay'
     permission_required = 'dcim.add_devicebay'
-    parent_model = Device
-    parent_field = 'device'
     model = DeviceBay
     model = DeviceBay
     form = forms.DeviceBayCreateForm
     form = forms.DeviceBayCreateForm
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
@@ -1784,8 +1860,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
 class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebay'
     permission_required = 'dcim.delete_devicebay'
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
-    parent_model = Device
     table = tables.DeviceBayTable
     table = tables.DeviceBayTable
+    default_return_url = 'dcim:devicebay_list'
 
 
 
 
 #
 #
@@ -1876,7 +1952,7 @@ class CableListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.CableFilterSet
     filterset = filters.CableFilterSet
     filterset_form = forms.CableFilterForm
     filterset_form = forms.CableFilterForm
     table = tables.CableTable
     table = tables.CableTable
-    template_name = 'dcim/cable_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class CableView(PermissionRequiredMixin, View):
 class CableView(PermissionRequiredMixin, View):
@@ -2148,7 +2224,7 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.InventoryItemFilterSet
     filterset = filters.InventoryItemFilterSet
     filterset_form = forms.InventoryItemFilterForm
     filterset_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
-    template_name = 'dcim/inventoryitem_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
 class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
@@ -2156,13 +2232,13 @@ class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
     model = InventoryItem
     model = InventoryItem
     model_form = forms.InventoryItemForm
     model_form = forms.InventoryItemForm
 
 
-    def alter_obj(self, obj, request, url_args, url_kwargs):
-        if 'device' in url_kwargs:
-            obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
-        return obj
 
 
-    def get_return_url(self, request, obj):
-        return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk})
+class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_inventoryitem'
+    model = InventoryItem
+    form = forms.InventoryItemCreateForm
+    model_form = forms.InventoryItemForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -2204,7 +2280,7 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
     filterset = filters.VirtualChassisFilterSet
     filterset = filters.VirtualChassisFilterSet
     filterset_form = forms.VirtualChassisFilterForm
     filterset_form = forms.VirtualChassisFilterForm
-    template_name = 'dcim/virtualchassis_list.html'
+    action_buttons = ('export',)
 
 
 
 
 class VirtualChassisCreateView(PermissionRequiredMixin, View):
 class VirtualChassisCreateView(PermissionRequiredMixin, View):
@@ -2448,7 +2524,6 @@ class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerPanelFilterSet
     filterset = filters.PowerPanelFilterSet
     filterset_form = forms.PowerPanelFilterForm
     filterset_form = forms.PowerPanelFilterForm
     table = tables.PowerPanelTable
     table = tables.PowerPanelTable
-    template_name = 'dcim/powerpanel_list.html'
 
 
 
 
 class PowerPanelView(PermissionRequiredMixin, View):
 class PowerPanelView(PermissionRequiredMixin, View):
@@ -2517,7 +2592,6 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerFeedFilterSet
     filterset = filters.PowerFeedFilterSet
     filterset_form = forms.PowerFeedFilterForm
     filterset_form = forms.PowerFeedFilterForm
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable
-    template_name = 'dcim/powerfeed_list.html'
 
 
 
 
 class PowerFeedView(PermissionRequiredMixin, View):
 class PowerFeedView(PermissionRequiredMixin, View):

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

@@ -20,6 +20,8 @@ from utilities.api import (
     ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
     ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
     ValidatedModelSerializer,
     ValidatedModelSerializer,
 )
 )
+from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer
+from virtualization.models import Cluster, ClusterGroup
 from .nested_serializers import *
 from .nested_serializers import *
 
 
 
 
@@ -161,6 +163,18 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         required=False,
         required=False,
         many=True
         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(
     tenant_groups = SerializedPKRelatedField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         serializer=NestedTenantGroupSerializer,
         serializer=NestedTenantGroupSerializer,
@@ -184,7 +198,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         model = ConfigContext
         model = ConfigContext
         fields = [
         fields = [
             'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
             '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
 router.APIRootView = ExtrasRootView
 
 
 # Field choices
 # Field choices
-router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
+router.register('_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
 
 
 # Custom field choices
 # Custom field choices
-router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
+router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
 
 
 # Graphs
 # Graphs
-router.register(r'graphs', views.GraphViewSet)
+router.register('graphs', views.GraphViewSet)
 
 
 # Export templates
 # Export templates
-router.register(r'export-templates', views.ExportTemplateViewSet)
+router.register('export-templates', views.ExportTemplateViewSet)
 
 
 # Tags
 # Tags
-router.register(r'tags', views.TagViewSet)
+router.register('tags', views.TagViewSet)
 
 
 # Image attachments
 # Image attachments
-router.register(r'image-attachments', views.ImageAttachmentViewSet)
+router.register('image-attachments', views.ImageAttachmentViewSet)
 
 
 # Config contexts
 # Config contexts
-router.register(r'config-contexts', views.ConfigContextViewSet)
+router.register('config-contexts', views.ConfigContextViewSet)
 
 
 # Reports
 # Reports
-router.register(r'reports', views.ReportViewSet, basename='report')
+router.register('reports', views.ReportViewSet, basename='report')
 
 
 # Scripts
 # Scripts
-router.register(r'scripts', views.ScriptViewSet, basename='script')
+router.register('scripts', views.ScriptViewSet, basename='script')
 
 
 # Change logging
 # Change logging
-router.register(r'object-changes', views.ObjectChangeViewSet)
+router.register('object-changes', views.ObjectChangeViewSet)
 
 
 app_name = 'extras-api'
 app_name = 'extras-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 0 - 20
netbox/extras/apps.py

@@ -1,28 +1,8 @@
 from django.apps import AppConfig
 from django.apps import AppConfig
-from django.conf import settings
-from django.core.exceptions import ImproperlyConfigured
-import redis
 
 
 
 
 class ExtrasConfig(AppConfig):
 class ExtrasConfig(AppConfig):
     name = "extras"
     name = "extras"
 
 
     def ready(self):
     def ready(self):
-
         import extras.signals
         import extras.signals
-
-        # Check that we can connect to the configured Redis database.
-        try:
-            rs = redis.Redis(
-                host=settings.WEBHOOKS_REDIS_HOST,
-                port=settings.WEBHOOKS_REDIS_PORT,
-                db=settings.WEBHOOKS_REDIS_DATABASE,
-                password=settings.WEBHOOKS_REDIS_PASSWORD or None,
-                ssl=settings.WEBHOOKS_REDIS_SSL,
-            )
-            rs.ping()
-        except redis.exceptions.ConnectionError:
-            raise ImproperlyConfigured(
-                "Unable to connect to the Redis database. Check that the Redis configuration has been defined in "
-                "configuration.py."
-            )

+ 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 dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
 from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
 from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
 
 
@@ -170,6 +171,22 @@ class ConfigContextFilterSet(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Platform (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(
     tenant_group_id = django_filters.ModelMultipleChoiceFilter(
         field_name='tenant_groups',
         field_name='tenant_groups',
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),

+ 148 - 135
netbox/extras/forms.py

@@ -1,18 +1,17 @@
-from collections import OrderedDict
-
 from django import forms
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
+from mptt.forms import TreeNodeMultipleChoiceField
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
     add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
-    CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
-    SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
+    CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
+    StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
+from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
 from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
 from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
 
 
@@ -21,102 +20,41 @@ from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachmen
 # Custom fields
 # 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):
     def __init__(self, *args, **kwargs):
 
 
-        self.custom_fields = []
         self.obj_type = ContentType.objects.get_for_model(self._meta.model)
         self.obj_type = ContentType.objects.get_for_model(self._meta.model)
+        self.custom_fields = []
+        self.custom_field_values = {}
 
 
         super().__init__(*args, **kwargs)
         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:
         if self.instance.pk:
-            existing_values = CustomFieldValue.objects.filter(
+            for cfv in CustomFieldValue.objects.filter(
                 obj_type=self.obj_type,
                 obj_type=self.obj_type,
                 obj_id=self.instance.pk
                 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):
     def _save_custom_fields(self):
 
 
@@ -151,6 +89,19 @@ class CustomFieldForm(forms.ModelForm):
         return obj
         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):
 class CustomFieldBulkEditForm(BulkEditForm):
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -160,15 +111,14 @@ class CustomFieldBulkEditForm(BulkEditForm):
         self.obj_type = ContentType.objects.get_for_model(self.model)
         self.obj_type = ContentType.objects.get_for_model(self.model)
 
 
         # Add all applicable CustomFields to the form
         # 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
             # 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
             # Annotate this as a custom field
-            self.custom_fields.append(name)
+            self.custom_fields.append(cf.name)
 
 
 
 
 class CustomFieldFilterForm(forms.Form):
 class CustomFieldFilterForm(forms.Form):
@@ -180,10 +130,12 @@ class CustomFieldFilterForm(forms.Form):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Add all applicable CustomFields to the form
         # 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)
 
 
 
 
 #
 #
@@ -239,7 +191,61 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
 #
 #
 
 
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):
 class ConfigContextForm(BootstrapMixin, forms.ModelForm):
-    tags = forms.ModelMultipleChoiceField(
+    regions = TreeNodeMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        widget=StaticSelect2Multiple()
+    )
+    sites = DynamicModelMultipleChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/"
+        )
+    )
+    roles = DynamicModelMultipleChoiceField(
+        queryset=DeviceRole.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/device-roles/"
+        )
+    )
+    platforms = DynamicModelMultipleChoiceField(
+        queryset=Platform.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/dcim/platforms/"
+        )
+    )
+    cluster_groups = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/cluster-groups/"
+        )
+    )
+    clusters = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/clusters/"
+        )
+    )
+    tenant_groups = DynamicModelMultipleChoiceField(
+        queryset=TenantGroup.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenant-groups/"
+        )
+    )
+    tenants = DynamicModelMultipleChoiceField(
+        queryset=Tenant.objects.all(),
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/tenancy/tenants/"
+        )
+    )
+    tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -253,30 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = ConfigContext
         model = ConfigContext
-        fields = [
-            'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
-            'tenants', 'tags', 'data',
-        ]
-        widgets = {
-            'regions': APISelectMultiple(
-                api_url="/api/dcim/regions/"
-            ),
-            'sites': APISelectMultiple(
-                api_url="/api/dcim/sites/"
-            ),
-            'roles': APISelectMultiple(
-                api_url="/api/dcim/device-roles/"
-            ),
-            'platforms': APISelectMultiple(
-                api_url="/api/dcim/platforms/"
-            ),
-            'tenant_groups': APISelectMultiple(
-                api_url="/api/tenancy/tenant-groups/"
-            ),
-            'tenants': APISelectMultiple(
-                api_url="/api/tenancy/tenants/"
-            ),
-        }
+        fields = (
+            'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
+            'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
+        )
 
 
 
 
 class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
 class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -308,57 +294,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/regions/",
             api_url="/api/dcim/regions/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         queryset=DeviceRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/device-roles/",
             api_url="/api/dcim/device-roles/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    platform = FilterChoiceField(
+    platform = DynamicModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/platforms/",
             api_url="/api/dcim/platforms/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    tenant_group = FilterChoiceField(
+    cluster_group = DynamicModelMultipleChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='slug',
+        required=False,
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/cluster-groups/",
+            value_field="slug",
+        )
+    )
+    cluster_id = DynamicModelMultipleChoiceField(
+        queryset=Cluster.objects.all(),
+        required=False,
+        label='Cluster',
+        widget=APISelectMultiple(
+            api_url="/api/virtualization/clusters/",
+        )
+    )
+    tenant_group = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         queryset=TenantGroup.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/tenancy/tenant-groups/",
             api_url="/api/tenancy/tenant-groups/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    tenant = FilterChoiceField(
+    tenant = DynamicModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/tenancy/tenants/",
             api_url="/api/tenancy/tenants/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
-    tag = FilterChoiceField(
+    tag = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/extras/tags/",
             api_url="/api/extras/tags/",
             value_field="slug",
             value_field="slug",
@@ -415,11 +425,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
     )
     )
     action = forms.ChoiceField(
     action = forms.ChoiceField(
         choices=add_blank_choice(ObjectChangeActionChoices),
         choices=add_blank_choice(ObjectChangeActionChoices),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     )
+    # TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users
     user = forms.ModelChoiceField(
     user = forms.ModelChoiceField(
         queryset=User.objects.order_by('username'),
         queryset=User.objects.order_by('username'),
-        required=False
+        required=False,
+        widget=StaticSelect2()
     )
     )
     changed_object_type = forms.ModelChoiceField(
     changed_object_type = forms.ModelChoiceField(
         queryset=ContentType.objects.order_by('model'),
         queryset=ContentType.objects.order_by('model'),

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

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

+ 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 collections import OrderedDict
 from datetime import date
 from datetime import date
 
 
+from django import forms
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
@@ -14,6 +15,7 @@ from django.utils.text import slugify
 from taggit.models import TagBase, GenericTaggedItemBase
 from taggit.models import TagBase, GenericTaggedItemBase
 
 
 from utilities.fields import ColorField
 from utilities.fields import ColorField
+from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
 from utilities.utils import deepmerge, render_jinja2
 from utilities.utils import deepmerge, render_jinja2
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
@@ -280,6 +282,75 @@ class CustomField(models.Model):
             return self.choices.get(pk=int(serialized_value))
             return self.choices.get(pk=int(serialized_value))
         return 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):
 class CustomFieldValue(models.Model):
     field = models.ForeignKey(
     field = models.ForeignKey(
@@ -694,6 +765,16 @@ class ConfigContext(models.Model):
         related_name='+',
         related_name='+',
         blank=True
         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(
     tenant_groups = models.ManyToManyField(
         to='tenancy.TenantGroup',
         to='tenancy.TenantGroup',
         related_name='+',
         related_name='+',

+ 6 - 0
netbox/extras/querysets.py

@@ -29,6 +29,10 @@ class ConfigContextQuerySet(QuerySet):
         # `device_role` for Device; `role` for VirtualMachine
         # `device_role` for Device; `role` for VirtualMachine
         role = getattr(obj, 'device_role', None) or obj.role
         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
         # Get the group of the assigned tenant, if any
         tenant_group = obj.tenant.group if obj.tenant else None
         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(sites=obj.site) | Q(sites=None),
             Q(roles=role) | Q(roles=None),
             Q(roles=role) | Q(roles=None),
             Q(platforms=obj.platform) | Q(platforms=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(tenant_groups=tenant_group) | Q(tenant_groups=None),
             Q(tenants=obj.tenant) | Q(tenants=None),
             Q(tenants=obj.tenant) | Q(tenants=None),
             Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),
             Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),

+ 7 - 2
netbox/extras/scripts.py

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

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

@@ -1,14 +1,15 @@
 from datetime import date
 from datetime import date
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
-from django.test import TestCase
+from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
+from dcim.forms import SiteCSVForm
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import *
 from extras.choices import *
 from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
 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
 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_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_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])
         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.filters import *
 from extras.models import ConfigContext, ExportTemplate, Graph
 from extras.models import ConfigContext, ExportTemplate, Graph
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
+from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 
 
 class GraphTestCase(TestCase):
 class GraphTestCase(TestCase):
@@ -107,6 +108,21 @@ class ConfigContextTestCase(TestCase):
         )
         )
         Platform.objects.bulk_create(platforms)
         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 = (
         tenant_groups = (
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
@@ -132,6 +148,8 @@ class ConfigContextTestCase(TestCase):
             c.sites.set([sites[i]])
             c.sites.set([sites[i]])
             c.roles.set([device_roles[i]])
             c.roles.set([device_roles[i]])
             c.platforms.set([platforms[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.tenant_groups.set([tenant_groups[i]])
             c.tenants.set([tenants[i]])
             c.tenants.set([tenants[i]])
 
 
@@ -173,6 +191,18 @@ class ConfigContextTestCase(TestCase):
         params = {'platform': [platforms[0].slug, platforms[1].slug]}
         params = {'platform': [platforms[0].slug, platforms[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_tenant_group(self):
         tenant_groups = TenantGroup.objects.all()[:2]
         tenant_groups = TenantGroup.objects.all()[:2]
         params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
         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
 import uuid
 
 
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
-from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
 from dcim.models import Site
 from dcim.models import Site
 from extras.choices import ObjectChangeActionChoices
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ConfigContext, ObjectChange, Tag
 from extras.models import ConfigContext, ObjectChange, Tag
-from utilities.testing import create_test_user
+from utilities.testing import ViewTestCases, TestCase
 
 
 
 
-class TagTestCase(TestCase):
+class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    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 1', slug='tag-1'),
             Tag(name='Tag 2', slug='tag-2'),
             Tag(name='Tag 2', slug='tag-2'),
             Tag(name='Tag 3', slug='tag-3'),
             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(ViewTestCases.PrimaryObjectViewTestCase):
+    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
         # Create three ConfigContexts
         for i in range(1, 4):
         for i in range(1, 4):
             configcontext = ConfigContext(
             configcontext = ConfigContext(
                 name='Config Context {}'.format(i),
                 name='Config Context {}'.format(i),
-                data='{{"foo": {}}}'.format(i)
+                data={'foo': i}
             )
             )
             configcontext.save()
             configcontext.save()
             configcontext.sites.add(site)
             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):
 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 = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
 
 
         # Create three ObjectChanges
         # Create three ObjectChanges
+        user = User.objects.create_user(username='testuser2')
         for i in range(1, 4):
         for i in range(1, 4):
             oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
             oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
             oc.user = user
             oc.user = user
@@ -96,10 +112,10 @@ class ObjectChangeTestCase(TestCase):
         }
         }
 
 
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
         response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
-        self.assertEqual(response.status_code, 200)
+        self.assertHttpStatus(response, 200)
 
 
     def test_objectchange(self):
     def test_objectchange(self):
 
 
         objectchange = ObjectChange.objects.first()
         objectchange = ObjectChange.objects.first()
         response = self.client.get(objectchange.get_absolute_url())
         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 = [
 urlpatterns = [
 
 
     # Tags
     # Tags
-    path(r'tags/', views.TagListView.as_view(), name='tag_list'),
-    path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
-    path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
-    path(r'tags/<str:slug>/', views.TagView.as_view(), name='tag'),
-    path(r'tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
-    path(r'tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
-    path(r'tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
+    path('tags/', views.TagListView.as_view(), name='tag_list'),
+    path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
+    path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
+    path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
+    path('tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
+    path('tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
+    path('tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
 
 
     # Config contexts
     # Config contexts
-    path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
-    path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
-    path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
-    path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
-    path(r'config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
-    path(r'config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
-    path(r'config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
+    path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
+    path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
+    path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
+    path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
+    path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
+    path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
+    path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
 
 
     # Image attachments
     # Image attachments
-    path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
-    path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
+    path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
+    path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
 
 
     # Change logging
     # Change logging
-    path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
-    path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
+    path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
+    path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
 
 
     # Reports
     # Reports
-    path(r'reports/', views.ReportListView.as_view(), name='report_list'),
-    path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
-    path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
+    path('reports/', views.ReportListView.as_view(), name='report_list'),
+    path('reports/<str:name>/', views.ReportView.as_view(), name='report'),
+    path('reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
 
 
     # Scripts
     # Scripts
-    path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
-    path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
+    path('scripts/', views.ScriptListView.as_view(), name='script_list'),
+    path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
 
 
 ]
 ]

+ 6 - 5
netbox/extras/views.py

@@ -34,10 +34,11 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.TagFilterSet
     filterset = filters.TagFilterSet
     filterset_form = forms.TagFilterForm
     filterset_form = forms.TagFilterForm
     table = TagTable
     table = TagTable
-    template_name = 'extras/tag_list.html'
+    action_buttons = ()
 
 
 
 
-class TagView(View):
+class TagView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_tag'
 
 
     def get(self, request, slug):
     def get(self, request, slug):
 
 
@@ -84,10 +85,9 @@ class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
     ).order_by(
     ).order_by(
         'name'
         'name'
     )
     )
-    # filter = filters.ProviderFilter
     table = TagTable
     table = TagTable
     form = forms.TagBulkEditForm
     form = forms.TagBulkEditForm
-    default_return_url = 'circuits:provider_list'
+    default_return_url = 'extras:tag_list'
 
 
 
 
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -111,7 +111,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConfigContextFilterSet
     filterset = filters.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
     filterset_form = forms.ConfigContextFilterForm
     table = ConfigContextTable
     table = ConfigContextTable
-    template_name = 'extras/configcontext_list.html'
+    action_buttons = ('add',)
 
 
 
 
 class ConfigContextView(PermissionRequiredMixin, View):
 class ConfigContextView(PermissionRequiredMixin, View):
@@ -191,6 +191,7 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
     filterset_form = forms.ObjectChangeFilterForm
     filterset_form = forms.ObjectChangeFilterForm
     table = ObjectChangeTable
     table = ObjectChangeTable
     template_name = 'extras/objectchange_list.html'
     template_name = 'extras/objectchange_list.html'
+    action_buttons = ('export',)
 
 
 
 
 class ObjectChangeView(PermissionRequiredMixin, View):
 class ObjectChangeView(PermissionRequiredMixin, View):

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

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

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

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

+ 10 - 0
netbox/ipam/api/views.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.conf import settings
 from django.db.models import Count
 from django.db.models import Count
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
+from django_pglocks import advisory_lock
 from rest_framework import status
 from rest_framework import status
 from rest_framework.decorators import action
 from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.exceptions import PermissionDenied
@@ -10,6 +11,7 @@ from extras.api.views import CustomFieldModelViewSet
 from ipam import filters
 from ipam import filters
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from utilities.api import FieldChoicesViewSet, ModelViewSet
 from utilities.api import FieldChoicesViewSet, ModelViewSet
+from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.utils import get_subquery
 from utilities.utils import get_subquery
 from . import serializers
 from . import serializers
 
 
@@ -86,9 +88,13 @@ class PrefixViewSet(CustomFieldModelViewSet):
     filterset_class = filters.PrefixFilterSet
     filterset_class = filters.PrefixFilterSet
 
 
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
+    @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
     def available_prefixes(self, request, pk=None):
     def available_prefixes(self, request, pk=None):
         """
         """
         A convenience method for returning available child prefixes within a parent.
         A convenience method for returning available child prefixes within a parent.
+
+        The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
+        invoked in parallel, which results in a race condition where multiple insertions can occur.
         """
         """
         prefix = get_object_or_404(Prefix, pk=pk)
         prefix = get_object_or_404(Prefix, pk=pk)
         available_prefixes = prefix.get_available_prefixes()
         available_prefixes = prefix.get_available_prefixes()
@@ -180,11 +186,15 @@ class PrefixViewSet(CustomFieldModelViewSet):
             return Response(serializer.data)
             return Response(serializer.data)
 
 
     @action(detail=True, url_path='available-ips', methods=['get', 'post'])
     @action(detail=True, url_path='available-ips', methods=['get', 'post'])
+    @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
     def available_ips(self, request, pk=None):
     def available_ips(self, request, pk=None):
         """
         """
         A convenience method for returning available IP addresses within a prefix. By default, the number of IPs
         A convenience method for returning available IP addresses within a prefix. By default, the number of IPs
         returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed,
         returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed,
         however results will not be paginated.
         however results will not be paginated.
+
+        The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
+        invoked in parallel, which results in a race condition where multiple insertions can occur.
         """
         """
         prefix = get_object_or_404(Prefix, pk=pk)
         prefix = get_object_or_404(Prefix, pk=pk)
 
 

+ 8 - 6
netbox/ipam/filters.py

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

+ 119 - 104
netbox/ipam/forms.py

@@ -4,13 +4,16 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import Device, Interface, Rack, Region, Site
 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.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
-    CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm,
-    SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
+    add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField,
+    DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
+    FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from .constants import *
 from .constants import *
@@ -31,7 +34,7 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
 # VRFs
 # VRFs
 #
 #
 
 
-class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -49,7 +52,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
 
 
 
 
-class VRFCSVForm(forms.ModelForm):
+class VRFCSVForm(CustomFieldModelCSVForm):
     tenant = forms.ModelChoiceField(
     tenant = forms.ModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
@@ -73,7 +76,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -103,6 +106,7 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 #
 #
@@ -144,7 +148,13 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
 # Aggregates
 # Aggregates
 #
 #
 
 
-class AggregateForm(BootstrapMixin, CustomFieldForm):
+class AggregateForm(BootstrapMixin, CustomFieldModelForm):
+    rir = DynamicModelChoiceField(
+        queryset=RIR.objects.all(),
+        widget=APISelect(
+            api_url="/api/ipam/rirs/"
+        )
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -159,14 +169,11 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
             'rir': "Regional Internet Registry responsible for this prefix",
             'rir': "Regional Internet Registry responsible for this prefix",
         }
         }
         widgets = {
         widgets = {
-            'rir': APISelect(
-                api_url="/api/ipam/rirs/"
-            ),
             'date_added': DatePicker(),
             'date_added': DatePicker(),
         }
         }
 
 
 
 
-class AggregateCSVForm(forms.ModelForm):
+class AggregateCSVForm(CustomFieldModelCSVForm):
     rir = forms.ModelChoiceField(
     rir = forms.ModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -186,7 +193,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         queryset=Aggregate.objects.all(),
         queryset=Aggregate.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    rir = forms.ModelChoiceField(
+    rir = DynamicModelChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         required=False,
         required=False,
         label='RIR',
         label='RIR',
@@ -223,15 +230,17 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
         label='Address family',
         label='Address family',
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    rir = FilterChoiceField(
+    rir = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=False,
         label='RIR',
         label='RIR',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/rirs/",
             api_url="/api/ipam/rirs/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 #
 #
@@ -263,11 +272,17 @@ class RoleCSVForm(forms.ModelForm):
 # Prefixes
 # Prefixes
 #
 #
 
 
-class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
-    site = forms.ModelChoiceField(
+class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/",
+        )
+    )
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
-        label='Site',
         widget=APISelect(
         widget=APISelect(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             filter_for={
             filter_for={
@@ -279,11 +294,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             }
             }
         )
         )
     )
     )
-    vlan_group = ChainedModelChoiceField(
+    vlan_group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
         required=False,
         required=False,
         label='VLAN group',
         label='VLAN group',
         widget=APISelect(
         widget=APISelect(
@@ -296,12 +308,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             }
             }
         )
         )
     )
     )
-    vlan = ChainedModelChoiceField(
+    vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
-        chains=(
-            ('site', 'site'),
-            ('group', 'vlan_group'),
-        ),
         required=False,
         required=False,
         label='VLAN',
         label='VLAN',
         widget=APISelect(
         widget=APISelect(
@@ -309,6 +317,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             display_field='display_name'
             display_field='display_name'
         )
         )
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/roles/"
+        )
+    )
     tags = TagField(required=False)
     tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
@@ -318,13 +333,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             'tags',
             'tags',
         ]
         ]
         widgets = {
         widgets = {
-            'vrf': APISelect(
-                api_url="/api/ipam/vrfs/"
-            ),
             'status': StaticSelect2(),
             'status': StaticSelect2(),
-            'role': APISelect(
-                api_url="/api/ipam/roles/"
-            )
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -341,7 +350,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
 
 
-class PrefixCSVForm(forms.ModelForm):
+class PrefixCSVForm(CustomFieldModelCSVForm):
     vrf = FlexibleModelChoiceField(
     vrf = FlexibleModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='rd',
         to_field_name='rd',
@@ -435,14 +444,14 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         queryset=Prefix.objects.all(),
         queryset=Prefix.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    site = forms.ModelChoiceField(
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/dcim/sites/"
             api_url="/api/dcim/sites/"
         )
         )
     )
     )
-    vrf = forms.ModelChoiceField(
+    vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
         label='VRF',
         label='VRF',
@@ -455,7 +464,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         max_value=PREFIX_LENGTH_MAX,
         max_value=PREFIX_LENGTH_MAX,
         required=False
         required=False
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -467,7 +476,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    role = forms.ModelChoiceField(
+    role = DynamicModelChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -521,10 +530,10 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         label='Mask length',
         label='Mask length',
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    vrf_id = FilterChoiceField(
+    vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        required=False,
         label='VRF',
         label='VRF',
-        null_label='-- Global --',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vrfs/",
             api_url="/api/ipam/vrfs/",
             null_option=True,
             null_option=True,
@@ -535,7 +544,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -547,20 +556,20 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/roles/",
             api_url="/api/ipam/roles/",
             value_field="slug",
             value_field="slug",
@@ -578,18 +587,27 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
         required=False,
         required=False,
         label='Expand prefix hierarchy'
         label='Expand prefix hierarchy'
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 #
 #
 # IP addresses
 # IP addresses
 #
 #
 
 
-class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
+class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
     interface = forms.ModelChoiceField(
     interface = forms.ModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False
         required=False
     )
     )
-    nat_site = forms.ModelChoiceField(
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF',
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/"
+        )
+    )
+    nat_site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         label='Site',
         label='Site',
@@ -601,11 +619,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
             }
             }
         )
         )
     )
     )
-    nat_rack = ChainedModelChoiceField(
+    nat_rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
-        chains=(
-            ('site', 'nat_site'),
-        ),
         required=False,
         required=False,
         label='Rack',
         label='Rack',
         widget=APISelect(
         widget=APISelect(
@@ -619,12 +634,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
             }
             }
         )
         )
     )
     )
-    nat_device = ChainedModelChoiceField(
+    nat_device = DynamicModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
-        chains=(
-            ('site', 'nat_site'),
-            ('rack', 'nat_rack'),
-        ),
         required=False,
         required=False,
         label='Device',
         label='Device',
         widget=APISelect(
         widget=APISelect(
@@ -646,11 +657,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
             }
             }
         )
         )
     )
     )
-    nat_inside = ChainedModelChoiceField(
+    nat_inside = DynamicModelChoiceField(
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
-        chains=(
-            ('interface__device', 'nat_device'),
-        ),
         required=False,
         required=False,
         label='IP Address',
         label='IP Address',
         widget=APISelect(
         widget=APISelect(
@@ -675,9 +683,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
             'role': StaticSelect2(),
             'role': StaticSelect2(),
-            'vrf': APISelect(
-                api_url="/api/ipam/vrfs/"
-            )
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -751,7 +756,15 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
     )
     )
 
 
 
 
-class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    vrf = DynamicModelChoiceField(
+        queryset=VRF.objects.all(),
+        required=False,
+        label='VRF',
+        widget=APISelect(
+            api_url="/api/ipam/vrfs/"
+        )
+    )
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
@@ -761,9 +774,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
             'role': StaticSelect2(),
             'role': StaticSelect2(),
-            'vrf': APISelect(
-                api_url="/api/ipam/vrfs/"
-            )
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -771,7 +781,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
 
 
-class IPAddressCSVForm(forms.ModelForm):
+class IPAddressCSVForm(CustomFieldModelCSVForm):
     vrf = FlexibleModelChoiceField(
     vrf = FlexibleModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         to_field_name='rd',
         to_field_name='rd',
@@ -899,7 +909,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    vrf = forms.ModelChoiceField(
+    vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
         label='VRF',
         label='VRF',
@@ -912,7 +922,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         max_value=IPADDRESS_MASK_LENGTH_MAX,
         max_value=IPADDRESS_MASK_LENGTH_MAX,
         required=False
         required=False
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -945,7 +955,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
 
 
 
 
 class IPAddressAssignForm(BootstrapMixin, forms.Form):
 class IPAddressAssignForm(BootstrapMixin, forms.Form):
-    vrf_id = forms.ModelChoiceField(
+    vrf_id = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
         label='VRF',
         label='VRF',
@@ -991,10 +1001,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
         label='Mask length',
         label='Mask length',
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    vrf_id = FilterChoiceField(
+    vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
+        required=False,
         label='VRF',
         label='VRF',
-        null_label='-- Global --',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vrfs/",
             api_url="/api/ipam/vrfs/",
             null_option=True,
             null_option=True,
@@ -1017,6 +1027,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 #
 #
@@ -1024,6 +1035,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
 #
 #
 
 
 class VLANGroupForm(BootstrapMixin, forms.ModelForm):
 class VLANGroupForm(BootstrapMixin, forms.ModelForm):
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/sites/"
+        )
+    )
     slug = SlugField()
     slug = SlugField()
 
 
     class Meta:
     class Meta:
@@ -1031,11 +1049,6 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
         fields = [
         fields = [
             'site', 'name', 'slug',
             'site', 'name', 'slug',
         ]
         ]
-        widgets = {
-            'site': APISelect(
-                api_url="/api/dcim/sites/"
-            )
-        }
 
 
 
 
 class VLANGroupCSVForm(forms.ModelForm):
 class VLANGroupCSVForm(forms.ModelForm):
@@ -1059,7 +1072,7 @@ class VLANGroupCSVForm(forms.ModelForm):
 
 
 
 
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 class VLANGroupFilterForm(BootstrapMixin, forms.Form):
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -1071,10 +1084,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- Global --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
@@ -1087,8 +1100,8 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
 # VLANs
 # VLANs
 #
 #
 
 
-class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
-    site = forms.ModelChoiceField(
+class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -1101,17 +1114,20 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
             }
             }
         )
         )
     )
     )
-    group = ChainedModelChoiceField(
+    group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
         required=False,
         required=False,
-        label='Group',
         widget=APISelect(
         widget=APISelect(
             api_url='/api/ipam/vlan-groups/',
             api_url='/api/ipam/vlan-groups/',
         )
         )
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=Role.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/ipam/roles/"
+        )
+    )
     tags = TagField(required=False)
     tags = TagField(required=False)
 
 
     class Meta:
     class Meta:
@@ -1129,13 +1145,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
         }
         }
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
-            'role': APISelect(
-                api_url="/api/ipam/roles/"
-            )
         }
         }
 
 
 
 
-class VLANCSVForm(forms.ModelForm):
+class VLANCSVForm(CustomFieldModelCSVForm):
     site = forms.ModelChoiceField(
     site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
@@ -1206,21 +1219,21 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    site = forms.ModelChoiceField(
+    site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/dcim/sites/"
             api_url="/api/dcim/sites/"
         )
         )
     )
     )
-    group = forms.ModelChoiceField(
+    group = DynamicModelChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/ipam/vlan-groups/"
             api_url="/api/ipam/vlan-groups/"
         )
         )
     )
     )
-    tenant = forms.ModelChoiceField(
+    tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -1232,7 +1245,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
-    role = forms.ModelChoiceField(
+    role = DynamicModelChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -1257,7 +1270,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    region = FilterChoiceField(
+    region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         required=False,
         required=False,
@@ -1270,20 +1283,20 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
             }
             }
         )
         )
     )
     )
-    site = FilterChoiceField(
+    site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- Global --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             value_field="slug",
             value_field="slug",
             null_option=True,
             null_option=True,
         )
         )
     )
     )
-    group_id = FilterChoiceField(
+    group_id = DynamicModelMultipleChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),
+        required=False,
         label='VLAN group',
         label='VLAN group',
-        null_label='-- None --',
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vlan-groups/",
             api_url="/api/ipam/vlan-groups/",
             null_option=True,
             null_option=True,
@@ -1294,23 +1307,24 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
         required=False,
         required=False,
         widget=StaticSelect2Multiple()
         widget=StaticSelect2Multiple()
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=Role.objects.all(),
         queryset=Role.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        null_label='-- None --',
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/roles/",
             api_url="/api/ipam/roles/",
             value_field="slug",
             value_field="slug",
             null_option=True,
             null_option=True,
         )
         )
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 #
 #
 # Services
 # Services
 #
 #
 
 
-class ServiceForm(BootstrapMixin, CustomFieldForm):
+class ServiceForm(BootstrapMixin, CustomFieldModelForm):
     port = forms.IntegerField(
     port = forms.IntegerField(
         min_value=SERVICE_PORT_MIN,
         min_value=SERVICE_PORT_MIN,
         max_value=SERVICE_PORT_MAX
         max_value=SERVICE_PORT_MAX
@@ -1364,6 +1378,7 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     port = forms.IntegerField(
     port = forms.IntegerField(
         required=False,
         required=False,
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -1390,5 +1405,5 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
 
     class Meta:
     class Meta:
         nullable_fields = [
         nullable_fields = [
-            'site', 'tenant', 'role', 'description',
+            'description',
         ]
         ]

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

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

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

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

+ 214 - 279
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 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 ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
-from utilities.testing import create_test_user
+from utilities.testing import ViewTestCases
 
 
 
 
-class VRFTestCase(TestCase):
+class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    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.objects.bulk_create([
             VRF(name='VRF 1', rd='65000:1'),
             VRF(name='VRF 1', rd='65000:1'),
@@ -28,48 +20,34 @@ class VRFTestCase(TestCase):
             VRF(name='VRF 3', rd='65000:3'),
             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",
             "name",
             "VRF 4",
             "VRF 4",
             "VRF 5",
             "VRF 5",
             "VRF 6",
             "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(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = RIR
 
 
-class RIRTestCase(TestCase):
-
-    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.objects.bulk_create([
             RIR(name='RIR 1', slug='rir-1'),
             RIR(name='RIR 1', slug='rir-1'),
@@ -77,91 +55,66 @@ class RIRTestCase(TestCase):
             RIR(name='RIR 3', slug='rir-3'),
             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",
             "name,slug",
             "RIR 4,rir-4",
             "RIR 4,rir-4",
             "RIR 5,rir-5",
             "RIR 5,rir-5",
             "RIR 6,rir-6",
             "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(ViewTestCases.PrimaryObjectViewTestCase):
+    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.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",
             "prefix,rir",
             "10.4.0.0/16,RIR 1",
             "10.4.0.0/16,RIR 1",
             "10.5.0.0/16,RIR 1",
             "10.5.0.0/16,RIR 1",
             "10.6.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)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(Aggregate.objects.count(), 6)
+        cls.bulk_edit_data = {
+            'rir': rirs[1].pk,
+            'date_added': datetime.date(2020, 1, 1),
+            'description': 'New description',
+        }
 
 
 
 
-class RoleTestCase(TestCase):
+class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = Role
 
 
-    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.objects.bulk_create([
             Role(name='Role 1', slug='role-1'),
             Role(name='Role 1', slug='role-1'),
@@ -169,146 +122,135 @@ class RoleTestCase(TestCase):
             Role(name='Role 3', slug='role-3'),
             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",
             "name,slug,weight",
             "Role 4,role-4,1000",
             "Role 4,role-4,1000",
             "Role 5,role-5,1000",
             "Role 5,role-5,1000",
             "Role 6,role-6,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(ViewTestCases.PrimaryObjectViewTestCase):
+    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.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",
             "prefix,status",
             "10.4.0.0/16,Active",
             "10.4.0.0/16,Active",
             "10.5.0.0/16,Active",
             "10.5.0.0/16,Active",
             "10.6.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(ViewTestCases.PrimaryObjectViewTestCase):
+    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.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",
             "address,status",
             "192.0.2.4/24,Active",
             "192.0.2.4/24,Active",
             "192.0.2.5/24,Active",
             "192.0.2.5/24,Active",
             "192.0.2.6/24,Active",
             "192.0.2.6/24,Active",
         )
         )
 
 
-        response = self.client.post(reverse('ipam:ipaddress_import'), {'csv': '\n'.join(csv_data)})
-
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(IPAddress.objects.count(), 6)
+        cls.bulk_edit_data = {
+            'vrf': vrfs[1].pk,
+            'tenant': None,
+            'status': IPAddressStatusChoices.STATUS_RESERVED,
+            'role': IPAddressRoleChoices.ROLE_ANYCAST,
+            'dns_name': 'example',
+            'description': 'New description',
+        }
 
 
 
 
-class VLANGroupTestCase(TestCase):
+class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = VLANGroup
 
 
-    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.objects.bulk_create([
             VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
             VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
@@ -316,104 +258,96 @@ class VLANGroupTestCase(TestCase):
             VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site),
             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",
             "name,slug",
             "VLAN Group 4,vlan-group-4",
             "VLAN Group 4,vlan-group-4",
             "VLAN Group 5,vlan-group-5",
             "VLAN Group 5,vlan-group-5",
             "VLAN Group 6,vlan-group-6",
             "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(ViewTestCases.PrimaryObjectViewTestCase):
+    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.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",
             "vid,name,status",
             "104,VLAN104,Active",
             "104,VLAN104,Active",
             "105,VLAN105,Active",
             "105,VLAN105,Active",
             "106,VLAN106,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(ViewTestCases.PrimaryObjectViewTestCase):
+    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.objects.bulk_create([
             Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101),
             Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101),
@@ -421,18 +355,19 @@ class ServiceTestCase(TestCase):
             Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103),
             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 = [
 urlpatterns = [
 
 
     # VRFs
     # VRFs
-    path(r'vrfs/', views.VRFListView.as_view(), name='vrf_list'),
-    path(r'vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'),
-    path(r'vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
-    path(r'vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
-    path(r'vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
-    path(r'vrfs/<int:pk>/', views.VRFView.as_view(), name='vrf'),
-    path(r'vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
-    path(r'vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
-    path(r'vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
+    path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
+    path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'),
+    path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
+    path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
+    path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
+    path('vrfs/<int:pk>/', views.VRFView.as_view(), name='vrf'),
+    path('vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
+    path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
+    path('vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
 
 
     # RIRs
     # RIRs
-    path(r'rirs/', views.RIRListView.as_view(), name='rir_list'),
-    path(r'rirs/add/', views.RIRCreateView.as_view(), name='rir_add'),
-    path(r'rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
-    path(r'rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
-    path(r'rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
-    path(r'vrfs/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
+    path('rirs/', views.RIRListView.as_view(), name='rir_list'),
+    path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'),
+    path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
+    path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
+    path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
+    path('vrfs/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
 
 
     # Aggregates
     # Aggregates
-    path(r'aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
-    path(r'aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'),
-    path(r'aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
-    path(r'aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
-    path(r'aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
-    path(r'aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
-    path(r'aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
-    path(r'aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
-    path(r'aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
+    path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
+    path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'),
+    path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
+    path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
+    path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
+    path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
+    path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
+    path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
+    path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
 
 
     # Roles
     # Roles
-    path(r'roles/', views.RoleListView.as_view(), name='role_list'),
-    path(r'roles/add/', views.RoleCreateView.as_view(), name='role_add'),
-    path(r'roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
-    path(r'roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
-    path(r'roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
-    path(r'roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
+    path('roles/', views.RoleListView.as_view(), name='role_list'),
+    path('roles/add/', views.RoleCreateView.as_view(), name='role_add'),
+    path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
+    path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
+    path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
+    path('roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
 
 
     # Prefixes
     # Prefixes
-    path(r'prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
-    path(r'prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'),
-    path(r'prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
-    path(r'prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
-    path(r'prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
-    path(r'prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
-    path(r'prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
-    path(r'prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
-    path(r'prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
-    path(r'prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
-    path(r'prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
+    path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
+    path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'),
+    path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
+    path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
+    path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
+    path('prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
+    path('prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
+    path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
+    path('prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
+    path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
+    path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
 
 
     # IP addresses
     # IP addresses
-    path(r'ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
-    path(r'ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
-    path(r'ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
-    path(r'ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
-    path(r'ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
-    path(r'ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
-    path(r'ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
-    path(r'ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
-    path(r'ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
-    path(r'ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
-    path(r'ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
+    path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
+    path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
+    path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
+    path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
+    path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
+    path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
+    path('ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
+    path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
+    path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
+    path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
+    path('ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
 
 
     # VLAN groups
     # VLAN groups
-    path(r'vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
-    path(r'vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
-    path(r'vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
-    path(r'vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
-    path(r'vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
-    path(r'vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
-    path(r'vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
+    path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
+    path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
+    path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
+    path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
+    path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
+    path('vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
+    path('vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
 
 
     # VLANs
     # VLANs
-    path(r'vlans/', views.VLANListView.as_view(), name='vlan_list'),
-    path(r'vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'),
-    path(r'vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
-    path(r'vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
-    path(r'vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
-    path(r'vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
-    path(r'vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
-    path(r'vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
-    path(r'vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
-    path(r'vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
+    path('vlans/', views.VLANListView.as_view(), name='vlan_list'),
+    path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'),
+    path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
+    path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
+    path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
+    path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
+    path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
+    path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
+    path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
+    path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
 
 
     # Services
     # Services
-    path(r'services/', views.ServiceListView.as_view(), name='service_list'),
-    path(r'services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
-    path(r'services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
-    path(r'services/<int:pk>/', views.ServiceView.as_view(), name='service'),
-    path(r'services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
-    path(r'services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
-    path(r'services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
+    path('services/', views.ServiceListView.as_view(), name='service_list'),
+    path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
+    path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
+    path('services/<int:pk>/', views.ServiceView.as_view(), name='service'),
+    path('services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
+    path('services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
+    path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
 
 
 ]
 ]

+ 1 - 7
netbox/ipam/views.py

@@ -118,7 +118,6 @@ class VRFListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.VRFFilterSet
     filterset = filters.VRFFilterSet
     filterset_form = forms.VRFFilterForm
     filterset_form = forms.VRFFilterForm
     table = tables.VRFTable
     table = tables.VRFTable
-    template_name = 'ipam/vrf_list.html'
 
 
 
 
 class VRFView(PermissionRequiredMixin, View):
 class VRFView(PermissionRequiredMixin, View):
@@ -293,7 +292,6 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView):
     queryset = Aggregate.objects.prefetch_related('rir').annotate(
     queryset = Aggregate.objects.prefetch_related('rir').annotate(
         child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
         child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
     )
     )
-
     filterset = filters.AggregateFilterSet
     filterset = filters.AggregateFilterSet
     filterset_form = forms.AggregateFilterForm
     filterset_form = forms.AggregateFilterForm
     table = tables.AggregateDetailTable
     table = tables.AggregateDetailTable
@@ -411,7 +409,6 @@ class RoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_role'
     permission_required = 'ipam.view_role'
     queryset = Role.objects.all()
     queryset = Role.objects.all()
     table = tables.RoleTable
     table = tables.RoleTable
-    template_name = 'ipam/role_list.html'
 
 
 
 
 class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
 class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -644,7 +641,6 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.IPAddressFilterSet
     filterset = filters.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
     filterset_form = forms.IPAddressFilterForm
     table = tables.IPAddressDetailTable
     table = tables.IPAddressDetailTable
-    template_name = 'ipam/ipaddress_list.html'
 
 
 
 
 class IPAddressView(PermissionRequiredMixin, View):
 class IPAddressView(PermissionRequiredMixin, View):
@@ -817,7 +813,6 @@ class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.VLANGroupFilterSet
     filterset = filters.VLANGroupFilterSet
     filterset_form = forms.VLANGroupFilterForm
     filterset_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
-    template_name = 'ipam/vlangroup_list.html'
 
 
 
 
 class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
 class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -893,7 +888,6 @@ class VLANListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.VLANFilterSet
     filterset = filters.VLANFilterSet
     filterset_form = forms.VLANFilterForm
     filterset_form = forms.VLANFilterForm
     table = tables.VLANDetailTable
     table = tables.VLANDetailTable
-    template_name = 'ipam/vlan_list.html'
 
 
 
 
 class VLANView(PermissionRequiredMixin, View):
 class VLANView(PermissionRequiredMixin, View):
@@ -989,7 +983,7 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ServiceFilterSet
     filterset = filters.ServiceFilterSet
     filterset_form = forms.ServiceFilterForm
     filterset_form = forms.ServiceFilterForm
     table = tables.ServiceTable
     table = tables.ServiceTable
-    template_name = 'ipam/service_list.html'
+    action_buttons = ('export',)
 
 
 
 
 class ServiceView(PermissionRequiredMixin, View):
 class ServiceView(PermissionRequiredMixin, View):

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

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

+ 51 - 21
netbox/netbox/settings.py

@@ -74,6 +74,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
 DEBUG = getattr(configuration, 'DEBUG', False)
 DEBUG = getattr(configuration, 'DEBUG', False)
+DEVELOPER = getattr(configuration, 'DEVELOPER', False)
 EMAIL = getattr(configuration, 'EMAIL', {})
 EMAIL = getattr(configuration, 'EMAIL', {})
 ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
@@ -169,18 +170,31 @@ if 'caching' not in REDIS:
 WEBHOOKS_REDIS = REDIS.get('webhooks', {})
 WEBHOOKS_REDIS = REDIS.get('webhooks', {})
 WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
 WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
 WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
 WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
+WEBHOOKS_REDIS_SENTINELS = WEBHOOKS_REDIS.get('SENTINELS', [])
+WEBHOOKS_REDIS_USING_SENTINEL = all([
+    isinstance(WEBHOOKS_REDIS_SENTINELS, (list, tuple)),
+    len(WEBHOOKS_REDIS_SENTINELS) > 0
+])
+WEBHOOKS_REDIS_SENTINEL_SERVICE = WEBHOOKS_REDIS.get('SENTINEL_SERVICE', 'default')
 WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
 WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
 WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
 WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
 WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
 WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
 WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
 WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
 
 
+
 CACHING_REDIS = REDIS.get('caching', {})
 CACHING_REDIS = REDIS.get('caching', {})
-CACHING_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
-CACHING_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
-CACHING_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
-CACHING_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
-CACHING_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
-CACHING_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
+CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost')
+CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379)
+CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', [])
+CACHING_REDIS_USING_SENTINEL = all([
+    isinstance(CACHING_REDIS_SENTINELS, (list, tuple)),
+    len(CACHING_REDIS_SENTINELS) > 0
+])
+CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
+CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
+CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
+CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300)
+CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
 
 
 
 
 #
 #
@@ -393,28 +407,35 @@ if LDAP_CONFIG is not None:
 #
 #
 # Caching
 # Caching
 #
 #
-
-if CACHING_REDIS_SSL:
-    REDIS_CACHE_CON_STRING = 'rediss://'
+if CACHING_REDIS_USING_SENTINEL:
+    CACHEOPS_SENTINEL = {
+        'locations': CACHING_REDIS_SENTINELS,
+        'service_name': CACHING_REDIS_SENTINEL_SERVICE,
+        'db': CACHING_REDIS_DATABASE,
+    }
 else:
 else:
-    REDIS_CACHE_CON_STRING = 'redis://'
-
-if CACHING_REDIS_PASSWORD:
-    REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
-
-REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
-    REDIS_CACHE_CON_STRING,
-    CACHING_REDIS_HOST,
-    CACHING_REDIS_PORT,
-    CACHING_REDIS_DATABASE
-)
+    if CACHING_REDIS_SSL:
+        REDIS_CACHE_CON_STRING = 'rediss://'
+    else:
+        REDIS_CACHE_CON_STRING = 'redis://'
+
+    if CACHING_REDIS_PASSWORD:
+        REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
+
+    REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
+        REDIS_CACHE_CON_STRING,
+        CACHING_REDIS_HOST,
+        CACHING_REDIS_PORT,
+        CACHING_REDIS_DATABASE
+    )
+    CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
 
 
 if not CACHE_TIMEOUT:
 if not CACHE_TIMEOUT:
     CACHEOPS_ENABLED = False
     CACHEOPS_ENABLED = False
 else:
 else:
     CACHEOPS_ENABLED = True
     CACHEOPS_ENABLED = True
 
 
-CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
+
 CACHEOPS_DEFAULTS = {
 CACHEOPS_DEFAULTS = {
     'timeout': CACHE_TIMEOUT
     'timeout': CACHE_TIMEOUT
 }
 }
@@ -533,6 +554,15 @@ RQ_QUEUES = {
         'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
         'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
         'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT,
         'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT,
         'SSL': WEBHOOKS_REDIS_SSL,
         'SSL': WEBHOOKS_REDIS_SSL,
+    } if not WEBHOOKS_REDIS_USING_SENTINEL else {
+        'SENTINELS': WEBHOOKS_REDIS_SENTINELS,
+        'MASTER_NAME': WEBHOOKS_REDIS_SENTINEL_SERVICE,
+        'DB': WEBHOOKS_REDIS_DATABASE,
+        'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
+        'SOCKET_TIMEOUT': None,
+        'CONNECTION_KWARGS': {
+            'socket_connect_timeout': WEBHOOKS_REDIS_DEFAULT_TIMEOUT
+        },
     }
     }
 }
 }
 
 

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

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

+ 27 - 27
netbox/netbox/urls.py

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

+ 1 - 1
netbox/netbox/views.py

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

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

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

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

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

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

+ 16 - 14
netbox/project-static/js/forms.js

@@ -190,15 +190,18 @@ $(document).ready(function() {
                 $.each(element.attributes, function(index, attr){
                 $.each(element.attributes, function(index, attr){
                     if (attr.name.includes("data-additional-query-param-")){
                     if (attr.name.includes("data-additional-query-param-")){
                         var param_name = attr.name.split("data-additional-query-param-")[1];
                         var param_name = attr.name.split("data-additional-query-param-")[1];
-                        if (param_name in parameters) {
-                            if (Array.isArray(parameters[param_name])) {
-                                parameters[param_name].push(attr.value)
+
+                        $.each($.parseJSON(attr.value), function(index, value) {
+                            if (param_name in parameters) {
+                                if (Array.isArray(parameters[param_name])) {
+                                    parameters[param_name].push(value);
+                                } else {
+                                    parameters[param_name] = [parameters[param_name], value];
+                                }
                             } else {
                             } else {
-                                parameters[param_name] = [parameters[param_name], attr.value]
+                                parameters[param_name] = value;
                             }
                             }
-                        } else {
-                            parameters[param_name] = attr.value;
-                        }
+                        });
                     }
                     }
                 });
                 });
 
 
@@ -220,19 +223,19 @@ $(document).ready(function() {
                     }
                     }
 
 
                     if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) {
                     if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) {
-                        results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }
+                        results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] };
                         results[record.site.name + ":" + record.group.name].children.push(record);
                         results[record.site.name + ":" + record.group.name].children.push(record);
                     }
                     }
                     else if( record.group !== undefined && record.group !== null ) {
                     else if( record.group !== undefined && record.group !== null ) {
-                        results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }
+                        results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] };
                         results[record.group.name].children.push(record);
                         results[record.group.name].children.push(record);
                     }
                     }
                     else if( record.site !== undefined && record.site !== null ) {
                     else if( record.site !== undefined && record.site !== null ) {
-                        results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }
+                        results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] };
                         results[record.site.name].children.push(record);
                         results[record.site.name].children.push(record);
                     }
                     }
                     else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) {
                     else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) {
-                        results['global'] = results['global'] || { text: 'Global', children: [] }
+                        results['global'] = results['global'] || { text: 'Global', children: [] };
                         results['global'].children.push(record);
                         results['global'].children.push(record);
                     }
                     }
                     else {
                     else {
@@ -246,10 +249,9 @@ $(document).ready(function() {
 
 
                 // Handle the null option, but only add it once
                 // Handle the null option, but only add it once
                 if (element.getAttribute('data-null-option') && data.previous === null) {
                 if (element.getAttribute('data-null-option') && data.previous === null) {
-                    var null_option = $(element).children()[0];
                     results.unshift({
                     results.unshift({
-                        id: null_option.value,
-                        text: null_option.text
+                        id: 'null',
+                        text: 'None'
                     });
                     });
                 }
                 }
 
 

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

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

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

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

+ 17 - 12
netbox/secrets/forms.py

@@ -4,10 +4,12 @@ from django import forms
 from taggit.forms import TagField
 from taggit.forms import TagField
 
 
 from dcim.models import Device
 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 (
 from utilities.forms import (
-    APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField,
-    StaticSelect2Multiple
+    APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
+    FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
 )
 )
 from .constants import *
 from .constants import *
 from .models import Secret, SecretRole, UserKey
 from .models import Secret, SecretRole, UserKey
@@ -68,7 +70,7 @@ class SecretRoleCSVForm(forms.ModelForm):
 # Secrets
 # Secrets
 #
 #
 
 
-class SecretForm(BootstrapMixin, CustomFieldForm):
+class SecretForm(BootstrapMixin, CustomFieldModelForm):
     plaintext = forms.CharField(
     plaintext = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
         required=False,
@@ -85,6 +87,12 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
         label='Plaintext (verify)',
         label='Plaintext (verify)',
         widget=forms.PasswordInput()
         widget=forms.PasswordInput()
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=SecretRole.objects.all(),
+        widget=APISelect(
+            api_url="/api/secrets/secret-roles/"
+        )
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -94,11 +102,6 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
         fields = [
         fields = [
             'role', 'name', 'plaintext', 'plaintext2', 'tags',
             'role', 'name', 'plaintext', 'plaintext2', 'tags',
         ]
         ]
-        widgets = {
-            'role': APISelect(
-                api_url="/api/secrets/secret-roles/"
-            )
-        }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -116,7 +119,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
             })
             })
 
 
 
 
-class SecretCSVForm(forms.ModelForm):
+class SecretCSVForm(CustomFieldModelCSVForm):
     device = FlexibleModelChoiceField(
     device = FlexibleModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -155,7 +158,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
         queryset=Secret.objects.all(),
         queryset=Secret.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    role = forms.ModelChoiceField(
+    role = DynamicModelChoiceField(
         queryset=SecretRole.objects.all(),
         queryset=SecretRole.objects.all(),
         required=False,
         required=False,
         widget=APISelect(
         widget=APISelect(
@@ -179,14 +182,16 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
         required=False,
         required=False,
         label='Search'
         label='Search'
     )
     )
-    role = FilterChoiceField(
+    role = DynamicModelMultipleChoiceField(
         queryset=SecretRole.objects.all(),
         queryset=SecretRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
+        required=True,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/secrets/secret-roles/",
             api_url="/api/secrets/secret-roles/",
             value_field="slug",
             value_field="slug",
         )
         )
     )
     )
+    tag = TagFilterField(model)
 
 
 
 
 #
 #

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

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

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

@@ -1,26 +1,18 @@
 import base64
 import base64
-import urllib.parse
 
 
-from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
 from secrets.models import Secret, SecretRole, SessionKey, UserKey
-from utilities.testing import create_test_user
+from utilities.testing import ViewTestCases
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 from .constants import PRIVATE_KEY, PUBLIC_KEY
 
 
 
 
-class SecretRoleTestCase(TestCase):
+class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = SecretRole
 
 
-    def setUp(self):
-        user = create_test_user(
-            permissions=[
-                'secrets.view_secretrole',
-                'secrets.add_secretrole',
-            ]
-        )
-        self.client = Client()
-        self.client.force_login(user)
+    @classmethod
+    def setUpTestData(cls):
 
 
         SecretRole.objects.bulk_create([
         SecretRole.objects.bulk_create([
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
             SecretRole(name='Secret Role 1', slug='secret-role-1'),
@@ -28,89 +20,83 @@ class SecretRoleTestCase(TestCase):
             SecretRole(name='Secret Role 3', slug='secret-role-3'),
             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",
             "name,slug",
             "Secret Role 4,secret-role-4",
             "Secret Role 4,secret-role-4",
             "Secret Role 5,secret-role-5",
             "Secret Role 5,secret-role-5",
             "Secret Role 6,secret-role-6",
             "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(ViewTestCases.PrimaryObjectViewTestCase):
+    model = Secret
 
 
-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)
+    # Disable inapplicable tests
+    test_create_object = None
 
 
-        self.client = Client()
-        self.client.force_login(user)
+    # TODO: Check permissions enforcement on secrets.views.secret_edit
+    test_edit_object = None
 
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+    @classmethod
+    def setUpTestData(cls):
 
 
-        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
-        manufacturer.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')
 
 
-        devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
-        devicetype.save()
-
-        devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
-        devicerole.save()
-
-        device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
-        device.save()
-
-        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 = (
         csv_data = (
             "device,role,name,plaintext",
             "device,role,name,plaintext",
@@ -125,5 +111,5 @@ class SecretTestCase(TestCase):
 
 
         response = self.client.post(reverse('secrets:secret_import'), {'csv': '\n'.join(csv_data)})
         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)
         self.assertEqual(Secret.objects.count(), 6)

+ 14 - 14
netbox/secrets/urls.py

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

+ 1 - 2
netbox/secrets/views.py

@@ -35,7 +35,6 @@ class SecretRoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'secrets.view_secretrole'
     permission_required = 'secrets.view_secretrole'
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     table = tables.SecretRoleTable
     table = tables.SecretRoleTable
-    template_name = 'secrets/secretrole_list.html'
 
 
 
 
 class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
 class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -73,7 +72,7 @@ class SecretListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.SecretFilterSet
     filterset = filters.SecretFilterSet
     filterset_form = forms.SecretFilterForm
     filterset_form = forms.SecretFilterForm
     table = tables.SecretTable
     table = tables.SecretTable
-    template_name = 'secrets/secret_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class SecretView(PermissionRequiredMixin, View):
 class SecretView(PermissionRequiredMixin, View):

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

@@ -1,22 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.circuits.add_circuit %}
-        {% add_button 'circuits:circuit_add' %}
-        {% import_button 'circuits:circuit_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Circuits{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/circuits/circuittype_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.circuits.add_circuittype %}
-        {% add_button 'circuits:circuittype_add' %}
-        {% import_button 'circuits:circuittype_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Circuit Types{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='circuits:circuittype_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -1,22 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.circuits.add_provider %}
-        {% add_button 'circuits:provider_add' %}
-        {% import_button 'circuits:provider_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Providers{% endblock %}</h1>
-<div class="row">
-    <div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 20
netbox/templates/dcim/cable_list.html

@@ -1,20 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_cable %}
-        {% import_button 'dcim:cable_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Cables{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

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

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

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

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

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

@@ -1,20 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-{% load helpers %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}{{ table.Meta.model|model_name|capfirst }}s{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'responsive_table.html' %}
-        {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
-    </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>
                 </table>
                 {% if perms.dcim.add_inventoryitem %}
                 {% if perms.dcim.add_inventoryitem %}
                     <div class="panel-footer text-right noprint">
                     <div class="panel-footer text-right noprint">
-                        <a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
+                        <a href="{% url 'dcim:inventoryitem_add' %}?device={{ device.pk }}&return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-primary btn-xs">
                             <span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
                             <span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
                         </a>
                         </a>
                     </div>
                     </div>

+ 21 - 19
netbox/templates/dcim/device_list.html

@@ -1,22 +1,24 @@
-{% extends '_base.html' %}
-{% load buttons %}
+{% extends 'utilities/obj_list.html' %}
 
 
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_device %}
-        {% add_button 'dcim:device_add' %}
-        {% import_button 'dcim:device_import' %}
+{% block bulk_buttons %}
+    {% if perms.dcim.change_device %}
+        <div class="btn-group">
+            <button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                <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:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
+                {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
+                {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
+                {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
+                {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
+                {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
+            </ul>
+        </div>
+    {% endif %}
+    {% if perms.dcim.add_virtualchassis %}
+        <button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
+            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
+        </button>
     {% endif %}
     {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Devices{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'dcim/inc/device_table.html' with bulk_edit_url='dcim:device_bulk_edit' bulk_delete_url='dcim:device_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
-    </div>
-</div>
 {% endblock %}
 {% endblock %}

+ 0 - 18
netbox/templates/dcim/devicerole_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_devicerole %}
-        {% add_button 'dcim:devicerole_add' %}
-        {% import_button 'dcim:devicerole_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Device Roles{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicerole_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

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

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

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

@@ -1,22 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_devicetype %}
-        {% add_button 'dcim:devicetype_add' %}
-        {% import_button 'dcim:devicetype_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Device Types{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 24
netbox/templates/dcim/inc/device_table.html

@@ -1,24 +0,0 @@
-{% extends 'utilities/obj_table.html' %}
-
-{% block extra_actions %}
-    {% if perms.dcim.change_device %}
-        <div class="btn-group">
-            <button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                <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:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
-                {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
-                {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
-                {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
-                {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
-                {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
-            </ul>
-        </div>
-    {% endif %}
-    {% if perms.dcim.add_virtualchassis %}
-        <button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
-        </button>
-    {% endif %}
-{% endblock %}

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

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

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

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

+ 0 - 21
netbox/templates/dcim/inventoryitem_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-{% load helpers %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_devicetype %}
-        {% import_button 'dcim:inventoryitem_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Inventory Items{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/dcim/manufacturer_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_manufacturer %}
-        {% add_button 'dcim:manufacturer_add' %}
-        {% import_button 'dcim:manufacturer_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Manufacturers{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:manufacturer_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/dcim/platform_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_platform %}
-        {% add_button 'dcim:platform_add' %}
-        {% import_button 'dcim:platform_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Platforms{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:platform_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -1,22 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_powerfeed %}
-        {% add_button 'dcim:powerfeed_add' %}
-        {% import_button 'dcim:powerfeed_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Power Feeds{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-		{% include 'inc/tags_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 21
netbox/templates/dcim/powerpanel_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_powerpanel %}
-        {% add_button 'dcim:powerpanel_add' %}
-        {% import_button 'dcim:powerpanel_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Power Panels{% endblock %}</h1>
-<div class="row">
-    <div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %}
-    </div>
-    <div class="col-md-3">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff