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

Merge pull request #3271 from digitalocean/develop

Release v2.6.0
Jeremy Stretch 6 лет назад
Родитель
Сommit
2b7e8c4b9f
100 измененных файлов с 4640 добавлено и 1072 удалено
  1. 1 0
      .travis.yml
  2. 220 0
      CHANGELOG.md
  3. 12 0
      base_requirements.txt
  4. 21 0
      docs/additional-features/caching.md
  5. 34 0
      docs/additional-features/prometheus-metrics.md
  6. 0 8
      docs/additional-features/webhooks.md
  7. 45 13
      docs/api/overview.md
  8. 47 54
      docs/configuration/optional-settings.md
  9. 41 0
      docs/configuration/required-settings.md
  10. 1 1
      docs/core-functionality/devices.md
  11. 1 0
      docs/development/release-checklist.md
  12. 3 25
      docs/installation/2-netbox.md
  13. 1 7
      docs/installation/migrating-to-python3.md
  14. 2 0
      mkdocs.yml
  15. 4 2
      netbox/circuits/api/nested_serializers.py
  16. 5 2
      netbox/circuits/api/serializers.py
  17. 7 2
      netbox/circuits/api/views.py
  18. 1 1
      netbox/circuits/filters.py
  19. 25 0
      netbox/circuits/migrations/0015_custom_tag_models.py
  20. 3 3
      netbox/circuits/models.py
  21. 2 2
      netbox/circuits/tests/test_api.py
  22. 10 6
      netbox/circuits/tests/test_views.py
  23. 2 1
      netbox/circuits/urls.py
  24. 12 5
      netbox/circuits/views.py
  25. 45 11
      netbox/dcim/api/nested_serializers.py
  26. 140 38
      netbox/dcim/api/serializers.py
  27. 4 0
      netbox/dcim/api/urls.py
  28. 101 21
      netbox/dcim/api/views.py
  29. 170 132
      netbox/dcim/constants.py
  30. 137 57
      netbox/dcim/filters.py
  31. 112 112
      netbox/dcim/fixtures/dcim.json
  32. 665 49
      netbox/dcim/forms.py
  33. 1 1
      netbox/dcim/managers.py
  34. 2 2
      netbox/dcim/migrations/0066_cables.py
  35. 85 0
      netbox/dcim/migrations/0070_custom_tag_models.py
  36. 38 0
      netbox/dcim/migrations/0071_device_components_add_description.py
  37. 134 0
      netbox/dcim/migrations/0072_powerfeeds.py
  38. 23 0
      netbox/dcim/migrations/0073_interface_form_factor_to_type.py
  39. 435 88
      netbox/dcim/models.py
  40. 77 10
      netbox/dcim/tables.py
  41. 308 27
      netbox/dcim/tests/test_api.py
  42. 1 1
      netbox/dcim/tests/test_models.py
  43. 38 40
      netbox/dcim/tests/test_views.py
  44. 32 8
      netbox/dcim/urls.py
  45. 294 46
      netbox/dcim/views.py
  46. 29 1
      netbox/extras/admin.py
  47. 5 2
      netbox/extras/api/serializers.py
  48. 3 0
      netbox/extras/api/urls.py
  49. 35 3
      netbox/extras/api/views.py
  50. 3 0
      netbox/extras/apps.py
  51. 118 25
      netbox/extras/constants.py
  52. 1 2
      netbox/extras/filters.py
  53. 5 5
      netbox/extras/forms.py
  54. 4 11
      netbox/extras/management/commands/nbshell.py
  55. 13 1
      netbox/extras/middleware.py
  56. 43 0
      netbox/extras/migrations/0019_tag_taggeditem.py
  57. 65 0
      netbox/extras/migrations/0020_tag_data.py
  58. 34 0
      netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py
  59. 48 0
      netbox/extras/migrations/0022_custom_links.py
  60. 109 5
      netbox/extras/models.py
  61. 22 0
      netbox/extras/signals.py
  62. 7 4
      netbox/extras/tables.py
  63. 0 0
      netbox/extras/templatetags/__init__.py
  64. 68 0
      netbox/extras/templatetags/custom_links.py
  65. 1 2
      netbox/extras/tests/test_api.py
  66. 32 1
      netbox/extras/tests/test_customfields.py
  67. 8 8
      netbox/extras/tests/test_views.py
  68. 3 0
      netbox/extras/urls.py
  69. 21 14
      netbox/extras/views.py
  70. 1 1
      netbox/extras/webhooks.py
  71. 9 4
      netbox/ipam/api/nested_serializers.py
  72. 14 7
      netbox/ipam/api/serializers.py
  73. 28 6
      netbox/ipam/api/views.py
  74. 18 4
      netbox/ipam/filters.py
  75. 10 5
      netbox/ipam/forms.py
  76. 45 0
      netbox/ipam/migrations/0025_custom_tag_models.py
  77. 18 0
      netbox/ipam/migrations/0026_prefix_ordering_vrf_nulls_first.py
  78. 19 0
      netbox/ipam/migrations/0027_ipaddress_add_dns_name.py
  79. 32 14
      netbox/ipam/models.py
  80. 2 2
      netbox/ipam/querysets.py
  81. 5 2
      netbox/ipam/tables.py
  82. 4 4
      netbox/ipam/tests/test_api.py
  83. 19 15
      netbox/ipam/tests/test_views.py
  84. 8 0
      netbox/ipam/validators.py
  85. 39 19
      netbox/ipam/views.py
  86. 0 3
      netbox/netbox/admin.py
  87. 18 7
      netbox/netbox/api.py
  88. 26 11
      netbox/netbox/configuration.example.py
  89. 1 0
      netbox/netbox/forms.py
  90. 252 97
      netbox/netbox/settings.py
  91. 5 0
      netbox/netbox/urls.py
  92. 14 4
      netbox/netbox/views.py
  93. 1 0
      netbox/project-static/css/base.css
  94. 2 1
      netbox/secrets/api/nested_serializers.py
  95. 2 1
      netbox/secrets/api/serializers.py
  96. 4 1
      netbox/secrets/api/views.py
  97. 2 2
      netbox/secrets/filters.py
  98. 20 0
      netbox/secrets/migrations/0006_custom_tag_models.py
  99. 2 2
      netbox/secrets/models.py
  100. 1 1
      netbox/secrets/tests/test_api.py

+ 1 - 0
.travis.yml

@@ -1,6 +1,7 @@
 sudo: required
 sudo: required
 services:
 services:
   - postgresql
   - postgresql
+  - redis-server
 addons:
 addons:
   postgresql: "9.4"
   postgresql: "9.4"
 language: python
 language: python

+ 220 - 0
CHANGELOG.md

@@ -1,3 +1,223 @@
+v2.6.0 (2019-06-20)
+
+## New Features
+
+### Power Panels and Feeds ([#54](https://github.com/digitalocean/netbox/issues/54))
+
+NetBox now supports power circuit modeling via two new models: power panels and power feeds. Power feeds are terminated
+to power panels and are optionally associated with individual racks. Each power feed defines a supply type (AC/DC),
+amperage, voltage, and phase. A power port can be connected directly to a power feed, but a power feed may have only one
+power port connected to it.
+
+Additionally, the power port model, which represents a device's power input, has been extended to include fields
+denoting maximum and allocated draw, in volt-amperes. This allows a device (e.g. a PDU) to calculate its total load
+compared to its connected power feed.
+
+### Caching ([#2647](https://github.com/digitalocean/netbox/issues/2647))
+
+To improve performance, NetBox now supports caching for most object and list views. Caching is implemented using Redis,
+which is now a required dependency. (Previously, Redis was required only if webhooks were enabled.)
+
+A new configuration parameter is available to control the cache timeout:
+
+```
+# Cache timeout (in seconds)
+CACHE_TIMEOUT = 900
+```
+
+### View Permissions ([#323](https://github.com/digitalocean/netbox/issues/323))
+
+Django 2.1 introduced the ability to enforce view-only permissions for different object types. NetBox now enforces
+these by default. You can grant view permission to a user or group by assigning the "can view" permission for the
+desired object(s).
+
+To exempt certain object types from the enforcement of view permissions, so that any user (including anonymous users)
+can view them, add them to the new `EXEMPT_VIEW_PERMISSIONS` setting in `configuration.py`:
+
+```
+EXEMPT_VIEW_PERMISSIONS = [
+    'dcim.site',
+    'ipam.prefix',
+]
+```
+
+To exclude _all_ objects, effectively disabling view permissions and restoring pre-v2.6 behavior, set:
+
+```
+EXEMPT_VIEW_PERMISSIONS = ['*']
+```
+
+### Custom Links ([#969](https://github.com/digitalocean/netbox/issues/969))
+
+Custom links are created under the admin UI and will be displayed on each object of the selected type. Link text and
+URLs can be formed from Jinja2 template code, with the viewed object passed as context data. For example, to link to an
+external NMS from the device view, you might create a custom link with the following URL:
+
+```
+https://nms.example.com/nodes/?name={{ obj.name }}
+```
+
+Custom links appear as buttons at the top of the object view. Grouped links will render as a dropdown menu beneath a
+single button.
+
+### Prometheus Metrics ([#3104](https://github.com/digitalocean/netbox/issues/3104))
+
+NetBox now supports exposing native Prometheus metrics from the application. [Prometheus](https://prometheus.io/) is a
+popular time series metric platform used for monitoring. Metric exposition can be toggled with the `METRICS_ENABLED`
+configuration setting; it is not enabled by default. NetBox exposes metrics at the `/metrics` HTTP endpoint, e.g.
+`https://netbox.local/metrics`.
+
+NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-prometheus) library to export a number of
+different types of metrics, including:
+
+* Per model insert, update, and delete counters
+* Per view request counters
+* Per view request latency histograms
+* Request body size histograms
+* Response body size histograms
+* Response code counters
+* Database connection, execution, and error counters
+* Cache hit, miss, and invalidation counters
+* Django middleware latency histograms
+* Other Django related metadata metrics
+
+For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on your NetBox instance. See the documentation
+for more details on using Prometheus metrics in NetBox.
+
+## Changes
+
+### New Dependency: Redis
+
+[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component
+of NetBox since the introduction of webhooks in version 2.4, it is now required to support NetBox's new caching
+functionality (as well as other planned features). Redis can be installed via your platform's package manager: for
+example, `sudo apt-get install redis-server` on Ubuntu or `sudo yum install redis` on CentOS.
+
+The Redis database is configured using a configuration setting similar to `DATABASE` in `configuration.py`:
+
+```
+REDIS = {
+    'HOST': 'localhost',
+    'PORT': 6379,
+    'PASSWORD': '',
+    'DATABASE': 0,
+    'CACHE_DATABASE': 1,
+    'DEFAULT_TIMEOUT': 300,
+    'SSL': False,
+}
+```
+
+Note that if you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but
+an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The
+`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting. It is
+highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result
+in webhook processing data being lost during cache flushing events.
+
+### API Support for Specifying Related Objects by Attributes([#3077](https://github.com/digitalocean/netbox/issues/3077))
+
+Previously, specifying a related object in an API request required knowing the primary key (integer ID) of that object.
+For example, when creating a new device, its rack would be specified as an integer:
+
+```
+{
+    "name": "MyNewDevice",
+    "rack": 123,
+    ...
+}
+```
+
+The NetBox API now also supports referencing related objects by a set of sufficiently unique attrbiutes. For example, a
+rack can be identified by its name and parent site:
+
+```
+{
+    "name": "MyNewDevice",
+    "rack": {
+        "site": {
+            "name": "Equinix DC6"
+        },
+        "name": "R204"
+    },
+    ...
+}
+```
+
+There is no limit to the depth of nested references. Note that if the provided parameters do not return exactly one
+object, a validation error is raised.
+
+### API Device/VM Config Context Included by Default ([#2350](https://github.com/digitalocean/netbox/issues/2350))
+
+The rendered config context for devices and VMs is now included by default in all API results (list and detail views).
+Previously, the rendered config context was available only in the detail view for individual objects. Users with large
+amounts of context data may observe a performance drop when returning multiple objects. To combat this, in cases where
+the rendered config context is not needed, the query parameter `?exclude=config_context` may be appended to the request
+URL to exclude the config context data from the API response.
+
+### Changes to Tag Permissions
+
+NetBox now makes use of its own `Tag` model instead of the stock model which ships with django-taggit. This new model
+lives in the `extras` app and thus any permissions that you may have configured using "Taggit | Tag" should be changed
+to now use "Extras | Tag." Also note that the admin interface for tags has been removed as it was redundant to the
+functionality provided by the front end UI.
+
+### CORS_ORIGIN_WHITELIST Requires URI Scheme
+
+If you have the `CORS_ORIGIN_WHITELIST` configuration parameter defined, note that each origin must now incldue a URI
+scheme. This change was introuced in django-cors-headers 3.0.
+
+## Enhancements
+
+* [#166](https://github.com/digitalocean/netbox/issues/166) - Add `dns_name` field to IPAddress
+* [#524](https://github.com/digitalocean/netbox/issues/524) - Added power utilization graphs to power feeds, devices, and racks
+* [#1792](https://github.com/digitalocean/netbox/issues/1792) - Add CustomFieldChoices API endpoint at `/api/extras/_custom_field_choices/`
+* [#1863](https://github.com/digitalocean/netbox/issues/1863) - Add child object counts to API representation of organizational objects
+* [#2324](https://github.com/digitalocean/netbox/issues/2324) - Add `color` field for tags
+* [#2643](https://github.com/digitalocean/netbox/issues/2643) - Add `description` field to console/power components and device bays
+* [#2791](https://github.com/digitalocean/netbox/issues/2791) - Add `comments` field for tags
+* [#2920](https://github.com/digitalocean/netbox/issues/2920) - Rename Interface `form_factor` to `type` (backward-compatible until v2.7)
+* [#2926](https://github.com/digitalocean/netbox/issues/2926) - Add change logging to the Tag model
+* [#3038](https://github.com/digitalocean/netbox/issues/3038) - OR logic now used when multiple values of a query filter are passed
+* [#3264](https://github.com/digitalocean/netbox/issues/3264) - Annotate changelog retention time on UI
+
+## Bug Fixes
+
+* [#2968](https://github.com/digitalocean/netbox/issues/2968) - Correct API documentation for SerializerMethodFields
+* [#3176](https://github.com/digitalocean/netbox/issues/3176) - Add cable trace button for console server ports and power outlets
+* [#3231](https://github.com/digitalocean/netbox/issues/3231) - Fixed cosmetic error indicating a missing schema migration
+* [#3239](https://github.com/digitalocean/netbox/issues/3239) - Corrected count of tags reported via API
+
+## Bug Fixes From v2.6-beta1
+
+* [#3123](https://github.com/digitalocean/netbox/issues/3123) - Exempt `/metrics` view from authentication
+* [#3125](https://github.com/digitalocean/netbox/issues/3125) - Fix exception when viewing PDUs
+* [#3126](https://github.com/digitalocean/netbox/issues/3126) - Incorrect calculation of PowerFeed available power
+* [#3130](https://github.com/digitalocean/netbox/issues/3130) - Fix exception when creating a new power outlet
+* [#3136](https://github.com/digitalocean/netbox/issues/3136) - Add power draw fields to power port creation form
+* [#3137](https://github.com/digitalocean/netbox/issues/3137) - Add `power_port` and `feed_leg` fields to power outlet creation form
+* [#3140](https://github.com/digitalocean/netbox/issues/3140) - Add bulk edit capability for power outlets and console server ports
+* [#3204](https://github.com/digitalocean/netbox/issues/3204) - Fix interface filtering when connecting cables
+* [#3207](https://github.com/digitalocean/netbox/issues/3207) - Fix link for connecting interface to rear port
+* [#3258](https://github.com/digitalocean/netbox/issues/3258) - Exception raised when creating/viewing a circuit with a non-connected termination
+
+## API Changes
+
+* New API endpoints for power modeling: `/api/dcim/power-panels/` and `/api/dcim/power-feeds/`
+* New API endpoint for custom field choices: `/api/extras/_custom_field_choices/`
+* ForeignKey fields now accept either the related object PK or a dictionary of attributes describing the related object.
+* Organizational objects now include child object counts. For example, the Role serializer includes `prefix_count` and `vlan_count`.
+* The `id__in` filter is now deprecated and will be removed in v2.7. (Begin using the `?id=1&id=2` format instead.)
+* Added a `description` field for all device components.
+* dcim.Device: The devices list endpoint now includes rendered context data.
+* dcim.DeviceType: `instance_count` has been renamed to `device_count`.
+* dcim.Interface: `form_factor` has been renamed to `type`. Backward compatibility for `form_factor` will be maintained until NetBox v2.7.
+* dcim.Interface: The `type` filter has been renamed to `kind`.
+* dcim.Site: The `count_*` read-only fields have been renamed to `*_count` for consistency with other objects.
+* dcim.Site: Added the `virtualmachine_count` read-only field.
+* extras.Tag: Added `color` and `comments` fields to the Tag serializer.
+* virtualization.VirtualMachine: The virtual machines list endpoint now includes rendered context data.
+
+---
+
 2.5.13 (2019-05-31)
 2.5.13 (2019-05-31)
 
 
 ## Enhancements
 ## Enhancements

+ 12 - 0
base_requirements.txt

@@ -2,6 +2,10 @@
 # https://github.com/django/django
 # https://github.com/django/django
 Django
 Django
 
 
+# Django caching using Redis
+# https://github.com/Suor/django-cacheops
+django-cacheops
+
 # Django middleware which permits cross-domain API requests
 # Django middleware which permits cross-domain API requests
 # https://github.com/OttoYiu/django-cors-headers
 # https://github.com/OttoYiu/django-cors-headers
 django-cors-headers
 django-cors-headers
@@ -18,6 +22,14 @@ django-filter
 # https://github.com/django-mptt/django-mptt
 # https://github.com/django-mptt/django-mptt
 django-mptt
 django-mptt
 
 
+# Django integration for RQ (Reqis queuing)
+# https://github.com/rq/django-rq
+django-rq
+
+# Prometheus metrics library for Django
+# https://github.com/korfuri/django-prometheus
+django-prometheus
+
 # Abstraction models for rendering and paginating HTML tables
 # Abstraction models for rendering and paginating HTML tables
 # https://github.com/jieter/django-tables2
 # https://github.com/jieter/django-tables2
 django-tables2
 django-tables2

+ 21 - 0
docs/additional-features/caching.md

@@ -0,0 +1,21 @@
+# Caching
+
+To improve performance, NetBox supports caching for most object and list views. Caching is implemented using Redis,
+and [django-cacheops](https://github.com/Suor/django-cacheops)
+
+Several management commands are avaliable for administrators to manaully invalidate cache entries in extenuating circumstances.
+
+To invalidate a specifc model instance (for example a Device with ID 34):
+```
+python netbox/manage.py invalidate dcim.Device.34
+```
+
+To invalidate all instance of a model:
+```
+python netbox/manage.py invalidate dcim.Device
+```
+
+To flush the entire cache database:
+```
+python netbox/manage.py invalidate all
+```

+ 34 - 0
docs/additional-features/prometheus-metrics.md

@@ -0,0 +1,34 @@
+# Prometheus Metrics
+
+NetBox supports optionally exposing native Prometheus metrics from the application. [Prometheus](https://prometheus.io/) is a popular time series metric platform used for monitoring.
+
+NetBox exposes metrics at the `/metrics` HTTP endpoint, e.g. `https://netbox.local/metrics`. Metric exposition can be toggled with the `METRICS_ENABLED` configuration setting. Metrics are not exposed by default.
+
+## Metric Types
+
+NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-prometheus) library to export a number of different types of metrics, including:
+
+- Per model insert, update, and delete counters
+- Per view request counters
+- Per view request latency histograms
+- Request body size histograms
+- Response body size histograms
+- Response code counters
+- Database connection, execution, and error counters
+- Cache hit, miss, and invalidation counters
+- Django middleware latency histograms
+- Other Django related metadata metrics
+
+For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on your NetBox instance.
+
+## Multi Processing Notes
+
+When deploying NetBox in a multiprocess mannor--such as using Gunicorn as recomented in the installation docs--the Prometheus client library requires the use of a shared directory
+to collect metrics from all the worker processes. This can be any arbitrary directory to which the processes have read/write access. This directory is then made available by use of the
+`prometheus_multiproc_dir` environment variable.
+
+This can be setup by first creating a shared directory and then adding this line (with the appropriate directory) to the `[program:netbox]` section of the supervisor config file.
+
+```
+environment=prometheus_multiproc_dir=/tmp/prometheus_metrics
+```

+ 0 - 8
docs/additional-features/webhooks.md

@@ -4,14 +4,6 @@ A webhook defines an HTTP request that is sent to an external application when c
 
 
 An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content.
 An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content.
 
 
-## Installation
-
-If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below.
-
-* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages)
-* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration)
-* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation)
-
 ## Requests
 ## Requests
 
 
 The webhook POST request is structured as so (assuming `application/json` as the Content-Type):
 The webhook POST request is structured as so (assuming `application/json` as the Content-Type):

+ 45 - 13
docs/api/overview.md

@@ -104,24 +104,37 @@ The base serializer is used to represent the default view of a model. This inclu
 }
 }
 ```
 ```
 
 
-Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
+## Related Objects
 
 
-When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
+Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to display the object to a user. When performing write API actions (`POST`, `PUT`, and `PATCH`), related objects may be specified by either numeric ID (primary key), or by a set of attributes sufficiently unique to return the desired object.
+
+For example, when creating a new device, its rack can be specified by NetBox ID (PK):
 
 
 ```
 ```
 {
 {
-    "id": 1201,
-    "site": 7,
-    "group": 4,
-    "vid": 102,
-    "name": "Users-Floor2",
-    "tenant": null,
-    "status": 1,
-    "role": 9,
-    "description": ""
+    "name": "MyNewDevice",
+    "rack": 123,
+    ...
+}
+```
+
+Or by a set of nested attributes used to identify the rack:
+
+```
+{
+    "name": "MyNewDevice",
+    "rack": {
+        "site": {
+            "name": "Equinix DC6"
+        },
+        "name": "R204"
+    },
+    ...
 }
 }
 ```
 ```
 
 
+Note that if the provided parameters do not return exactly one object, a validation error is raised.
+
 ## Brief Format
 ## Brief Format
 
 
 Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
 Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
@@ -261,12 +274,31 @@ A list of objects retrieved via the API can be filtered by passing one or more q
 GET /api/ipam/prefixes/?status=1
 GET /api/ipam/prefixes/?status=1
 ```
 ```
 
 
-Certain filters can be included multiple times within a single request. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes:
+The choices available for fixed choice fields such as `status` are exposed in the API under a special `_choices` endpoint for each NetBox app. For example, the available choices for `Prefix.status` are listed at `/api/ipam/_choices/` under the key `prefix:status`:
 
 
 ```
 ```
-GET /api/ipam/prefixes/?status=1&status=2
+"prefix:status": [
+    {
+        "label": "Container",
+        "value": 0
+    },
+    {
+        "label": "Active",
+        "value": 1
+    },
+    {
+        "label": "Reserved",
+        "value": 2
+    },
+    {
+        "label": "Deprecated",
+        "value": 3
+    }
+],
 ```
 ```
 
 
+For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar".
+
 ## Custom Fields
 ## Custom Fields
 
 
 To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:
 To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:

+ 47 - 54
docs/configuration/optional-settings.md

@@ -44,6 +44,14 @@ BASE_PATH = 'netbox/'
 
 
 ---
 ---
 
 
+## CACHE_TIMEOUT
+
+Default: 900
+
+The number of seconds to retain cache entries before automatically invalidating them.
+
+---
+
 ## CHANGELOG_RETENTION
 ## CHANGELOG_RETENTION
 
 
 Default: 90
 Default: 90
@@ -64,7 +72,13 @@ If True, cross-origin resource sharing (CORS) requests will be accepted from all
 
 
 ## CORS_ORIGIN_REGEX_WHITELIST
 ## CORS_ORIGIN_REGEX_WHITELIST
 
 
-These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.)
+These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) For example:
+
+```
+CORS_ORIGIN_WHITELIST = [
+    'https://example.com',
+]
+```
 
 
 ---
 ---
 
 
@@ -89,6 +103,30 @@ In order to send email, NetBox needs an email server configured. The following i
 
 
 ---
 ---
 
 
+## EXEMPT_VIEW_PERMISSIONS
+
+Default: Empty list
+
+A list of models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users and by anonymous users.
+
+List models in the form `<app>.<model>`. For example:
+
+```
+EXEMPT_VIEW_PERMISSIONS = [
+    'dcim.site',
+    'dcim.region',
+    'ipam.prefix',
+]
+```
+
+To exempt _all_ models from view permission enforcement, set the following. (Note that `EXEMPT_VIEW_PERMISSIONS` must be an iterable.)
+
+```
+EXEMPT_VIEW_PERMISSIONS = ['*']
+```
+
+---
+
 # ENFORCE_GLOBAL_UNIQUE
 # ENFORCE_GLOBAL_UNIQUE
 
 
 Default: False
 Default: False
@@ -165,6 +203,14 @@ The file path to the location where media files (such as image attachments) are
 
 
 ---
 ---
 
 
+## METRICS_ENABLED
+
+Default: False
+
+Toggle exposing Prometheus metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics/) documentation for more details.
+
+---
+
 ## NAPALM_USERNAME
 ## NAPALM_USERNAME
 
 
 ## NAPALM_PASSWORD
 ## NAPALM_PASSWORD
@@ -269,56 +315,3 @@ SHORT_TIME_FORMAT = 'H:i:s'          # 13:23:00
 DATETIME_FORMAT = 'N j, Y g:i a'     # June 26, 2016 1:23 p.m.
 DATETIME_FORMAT = 'N j, Y g:i a'     # June 26, 2016 1:23 p.m.
 SHORT_DATETIME_FORMAT = 'Y-m-d H:i'  # 2016-06-27 13:23
 SHORT_DATETIME_FORMAT = 'Y-m-d H:i'  # 2016-06-27 13:23
 ```
 ```
-
----
-
-## Redis Connection Settings
-
-[Redis](https://redis.io/) is a key-value store which functions as a very lightweight database. It is required when enabling NetBox [webhooks](../additional-features/webhooks/). A Redis connection is configured using a dictionary similar to the following:
-
-```
-REDIS = {
-    'HOST': 'localhost',
-    'PORT': 6379,
-    'PASSWORD': '',
-    'DATABASE': 0,
-    'DEFAULT_TIMEOUT': 300,
-    'SSL': False,
-}
-```
-
-### DATABASE
-
-Default: 0
-
-The Redis database ID.
-
-### DEFAULT_TIMEOUT
-
-Default: 300
-
-The timeout value to use when connecting to the Redis server (in seconds).
-
-### HOST
-
-Default: localhost
-
-The hostname or IP address of the Redis server.
-
-### PORT
-
-Default: 6379
-
-The TCP port to use when connecting to the Redis server.
-
-### PASSWORD
-
-Default: None
-
-The password to use when authenticating to the Redis server (optional).
-
-### SSL
-
-Default: False
-
-Use secure sockets layer to encrypt the connections to the Redis server.

+ 41 - 0
docs/configuration/required-settings.md

@@ -43,3 +43,44 @@ This is a secret cryptographic key is used to improve the security of cookies an
 Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
 Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
 
 
 `SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.
 `SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.
+
+---
+
+## REDIS
+
+[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of
+NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching
+functionality (as well as other planned features).
+
+Redis is configured using a configuration setting similar to `DATABASE`:
+
+* HOST - Name or IP address of the Redis server (use `localhost` if running locally)
+* PORT - TCP port of the Redis service; leave blank for default port (6379)
+* PASSWORD - Redis password (if set)
+* DATABASE - Numeric database ID for webhooks
+* CACHE_DATABASE - Numeric database ID for caching
+* DEFAULT_TIMEOUT - Connection timeout in seconds
+* SSL - Use SSL connection to Redis
+
+Example:
+
+```
+REDIS = {
+    'HOST': 'localhost',
+    'PORT': 6379,
+    'PASSWORD': '',
+    'DATABASE': 0,
+    'CACHE_DATABASE': 1,
+    'DEFAULT_TIMEOUT': 300,
+    'SSL': False,
+}
+```
+
+!!! note:
+    If you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but
+    an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The
+    `DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting. 
+
+!!! warning:
+    It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook
+    processing data being lost in cache flushing events.

+ 1 - 1
docs/core-functionality/devices.md

@@ -81,7 +81,7 @@ Power ports connect only to power outlets. Power connections can be marked as ei
 
 
 Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. Each type of connection can be classified as either *planned* or *connected*.
 Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. Each type of connection can be classified as either *planned* or *connected*.
 
 
-Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned.
+Each interface is a assigned a type denoting its physical properties. Two special types exist: the "virtual" type can be used to designate logical interfaces (such as SVIs), and the "LAG" type can be used to desinate link aggregation groups to which physical interfaces can be assigned.
 
 
 Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Fields are also provided to store an interface's MTU and MAC address.
 Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Fields are also provided to store an interface's MTU and MAC address.
 
 

+ 1 - 0
docs/development/release-checklist.md

@@ -29,6 +29,7 @@ Update the following static libraries to their most recent stable release:
 
 
 * Bootstrap 3
 * Bootstrap 3
 * Font Awesome 4
 * Font Awesome 4
+* Select2
 * jQuery
 * jQuery
 * jQuery UI
 * jQuery UI
 
 

+ 3 - 25
docs/installation/2-netbox.md

@@ -1,18 +1,18 @@
 # Installation
 # Installation
 
 
-This section of the documentation discusses installing and configuring the NetBox application.
+This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies:
 
 
 **Ubuntu**
 **Ubuntu**
 
 
 ```no-highlight
 ```no-highlight
-# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
+# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev redis-server zlib1g-dev
 ```
 ```
 
 
 **CentOS**
 **CentOS**
 
 
 ```no-highlight
 ```no-highlight
 # yum install -y epel-release
 # yum install -y epel-release
-# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
+# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config redis
 # easy_install-3.6 pip
 # easy_install-3.6 pip
 # ln -s /usr/bin/python36 /usr/bin/python3
 # ln -s /usr/bin/python36 /usr/bin/python3
 ```
 ```
@@ -90,28 +90,6 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati
 # pip3 install napalm
 # pip3 install napalm
 ```
 ```
 
 
-## Webhooks (Optional)
-
-[Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one.
-
-**Ubuntu**
-
-```no-highlight
-# apt-get install -y redis-server
-```
-
-**CentOS**
-
-```no-highlight
-# yum install -y redis
-```
-
-Enabling webhooks also requires installing the [`django-rq`](https://github.com/ui/django-rq) package. This allows NetBox to use the Redis database as a queue for outgoing webhooks.
-
-```no-highlight
-# pip3 install django-rq
-```
-
 # Configuration
 # Configuration
 
 
 Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
 Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.

+ 1 - 7
docs/installation/migrating-to-python3.md

@@ -1,7 +1,7 @@
 # Migration
 # Migration
 
 
 !!! warning
 !!! warning
-    Beginning with v2.5, NetBox will no longer support Python 2. It is strongly recommended that you upgrade to Python 3 as soon as possible.
+    As of version 2.5, NetBox no longer supports Python 2. Python 3 is required to run any 2.5 release or later.
 
 
 ## Ubuntu
 ## Ubuntu
 
 
@@ -36,9 +36,3 @@ If using LDAP authentication, install the `django-auth-ldap` package:
 ```no-highlight
 ```no-highlight
 # pip3 install django-auth-ldap
 # pip3 install django-auth-ldap
 ```
 ```
-
-If using Webhooks, install the `django-rq` package:
-
-```no-highlight
-# pip3 install django-rq
-```

+ 2 - 0
mkdocs.yml

@@ -36,6 +36,8 @@ pages:
         - Reports: 'additional-features/reports.md'
         - Reports: 'additional-features/reports.md'
         - Webhooks: 'additional-features/webhooks.md'
         - Webhooks: 'additional-features/webhooks.md'
         - Change Logging: 'additional-features/change-logging.md'
         - Change Logging: 'additional-features/change-logging.md'
+        - Caching: 'additional-features/caching.md'
+        - Prometheus Metrics: 'additional-features/prometheus-metrics.md'
     - Administration:
     - Administration:
         - Replicating NetBox: 'administration/replicating-netbox.md'
         - Replicating NetBox: 'administration/replicating-netbox.md'
         - NetBox Shell: 'administration/netbox-shell.md'
         - NetBox Shell: 'administration/netbox-shell.md'

+ 4 - 2
netbox/circuits/api/nested_serializers.py

@@ -17,10 +17,11 @@ __all__ = [
 
 
 class NestedProviderSerializer(WritableNestedSerializer):
 class NestedProviderSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
+    circuit_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'circuit_count']
 
 
 
 
 #
 #
@@ -29,10 +30,11 @@ class NestedProviderSerializer(WritableNestedSerializer):
 
 
 class NestedCircuitTypeSerializer(WritableNestedSerializer):
 class NestedCircuitTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
+    circuit_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'circuit_count']
 
 
 
 
 class NestedCircuitSerializer(WritableNestedSerializer):
 class NestedCircuitSerializer(WritableNestedSerializer):

+ 5 - 2
netbox/circuits/api/serializers.py

@@ -1,3 +1,4 @@
+from rest_framework import serializers
 from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 
 
 from circuits.constants import CIRCUIT_STATUS_CHOICES
 from circuits.constants import CIRCUIT_STATUS_CHOICES
@@ -16,12 +17,13 @@ from .nested_serializers import *
 
 
 class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
+    circuit_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = [
         fields = [
             'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
             'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
-            'custom_fields', 'created', 'last_updated',
+            'custom_fields', 'created', 'last_updated', 'circuit_count',
         ]
         ]
 
 
 
 
@@ -30,10 +32,11 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
 #
 #
 
 
 class CircuitTypeSerializer(ValidatedModelSerializer):
 class CircuitTypeSerializer(ValidatedModelSerializer):
+    circuit_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'name', 'slug', 'circuit_count']
 
 
 
 
 class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):

+ 7 - 2
netbox/circuits/api/views.py

@@ -1,3 +1,4 @@
+from django.db.models import Count
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from rest_framework.decorators import action
 from rest_framework.decorators import action
 from rest_framework.response import Response
 from rest_framework.response import Response
@@ -27,7 +28,9 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
 #
 #
 
 
 class ProviderViewSet(CustomFieldModelViewSet):
 class ProviderViewSet(CustomFieldModelViewSet):
-    queryset = Provider.objects.prefetch_related('tags')
+    queryset = Provider.objects.prefetch_related('tags').annotate(
+        circuit_count=Count('circuits')
+    )
     serializer_class = serializers.ProviderSerializer
     serializer_class = serializers.ProviderSerializer
     filterset_class = filters.ProviderFilter
     filterset_class = filters.ProviderFilter
 
 
@@ -47,7 +50,9 @@ class ProviderViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class CircuitTypeViewSet(ModelViewSet):
 class CircuitTypeViewSet(ModelViewSet):
-    queryset = CircuitType.objects.all()
+    queryset = CircuitType.objects.annotate(
+        circuit_count=Count('circuits')
+    )
     serializer_class = serializers.CircuitTypeSerializer
     serializer_class = serializers.CircuitTypeSerializer
     filterset_class = filters.CircuitTypeFilter
     filterset_class = filters.CircuitTypeFilter
 
 

+ 1 - 1
netbox/circuits/filters.py

@@ -51,7 +51,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = CircuitType
         model = CircuitType
-        fields = ['name', 'slug']
+        fields = ['id', 'name', 'slug']
 
 
 
 
 class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
 class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):

+ 25 - 0
netbox/circuits/migrations/0015_custom_tag_models.py

@@ -0,0 +1,25 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0014_circuittermination_description'),
+        ('extras', '0019_tag_taggeditem'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='circuit',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='provider',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 3 - 3
netbox/circuits/models.py

@@ -6,7 +6,7 @@ from taggit.managers import TaggableManager
 from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
 from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from dcim.models import CableTermination
 from dcim.models import CableTermination
-from extras.models import CustomFieldModel, ObjectChange
+from extras.models import CustomFieldModel, ObjectChange, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
 from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
 from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
@@ -55,7 +55,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
     csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
 
 
@@ -165,7 +165,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
         'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',

+ 2 - 2
netbox/circuits/tests/test_api.py

@@ -61,7 +61,7 @@ class ProviderTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['circuit_count', 'id', 'name', 'slug', 'url']
         )
         )
 
 
     def test_create_provider(self):
     def test_create_provider(self):
@@ -162,7 +162,7 @@ class CircuitTypeTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['circuit_count', 'id', 'name', 'slug', 'url']
         )
         )
 
 
     def test_create_circuittype(self):
     def test_create_circuittype(self):

+ 10 - 6
netbox/circuits/tests/test_views.py

@@ -4,13 +4,15 @@ from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
 from circuits.models import Circuit, CircuitType, Provider
 from circuits.models import Circuit, CircuitType, Provider
+from utilities.testing import create_test_user
 
 
 
 
 class ProviderTestCase(TestCase):
 class ProviderTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_provider'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         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),
@@ -38,8 +40,9 @@ class ProviderTestCase(TestCase):
 class CircuitTypeTestCase(TestCase):
 class CircuitTypeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_circuittype'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         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'),
@@ -58,8 +61,9 @@ class CircuitTypeTestCase(TestCase):
 class CircuitTestCase(TestCase):
 class CircuitTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['circuits.view_circuit'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
         provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
         provider.save()
         provider.save()
@@ -84,8 +88,8 @@ class CircuitTestCase(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.assertEqual(response.status_code, 200)
 
 
-    def test_provider(self):
+    def test_circuit(self):
 
 
-        provider = Provider.objects.first()
-        response = self.client.get(provider.get_absolute_url())
+        circuit = Circuit.objects.first()
+        response = self.client.get(circuit.get_absolute_url())
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)

+ 2 - 1
netbox/circuits/urls.py

@@ -40,10 +40,11 @@ urlpatterns = [
     path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
     path(r'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'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>/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:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
-    path(r'circuit-terminations/<int:termination_a_id>/connect/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
+    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(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
 
 
 ]
 ]

+ 12 - 5
netbox/circuits/views.py

@@ -1,9 +1,11 @@
+from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.db import transaction
 from django.db import transaction
 from django.db.models import Count
 from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.decorators import method_decorator
 from django.views.generic import View
 from django.views.generic import View
 
 
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
@@ -20,7 +22,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
 # Providers
 # Providers
 #
 #
 
 
-class ProviderListView(ObjectListView):
+class ProviderListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'circuits.view_provider'
     queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
     filter = filters.ProviderFilter
     filter = filters.ProviderFilter
     filter_form = forms.ProviderFilterForm
     filter_form = forms.ProviderFilterForm
@@ -28,7 +31,8 @@ class ProviderListView(ObjectListView):
     template_name = 'circuits/provider_list.html'
     template_name = 'circuits/provider_list.html'
 
 
 
 
-class ProviderView(View):
+class ProviderView(PermissionRequiredMixin, View):
+    permission_required = 'circuits.view_provider'
 
 
     def get(self, request, slug):
     def get(self, request, slug):
 
 
@@ -93,7 +97,8 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Circuit Types
 # Circuit Types
 #
 #
 
 
-class CircuitTypeListView(ObjectListView):
+class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
+    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'
     template_name = 'circuits/circuittype_list.html'
@@ -128,7 +133,8 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Circuits
 # Circuits
 #
 #
 
 
-class CircuitListView(ObjectListView):
+class CircuitListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'circuits.view_circuit'
     queryset = Circuit.objects.select_related(
     queryset = Circuit.objects.select_related(
         'provider', 'type', 'tenant'
         'provider', 'type', 'tenant'
     ).prefetch_related(
     ).prefetch_related(
@@ -140,7 +146,8 @@ class CircuitListView(ObjectListView):
     template_name = 'circuits/circuit_list.html'
     template_name = 'circuits/circuit_list.html'
 
 
 
 
-class CircuitView(View):
+class CircuitView(PermissionRequiredMixin, View):
+    permission_required = 'circuits.view_circuit'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 

+ 45 - 11
netbox/dcim/api/nested_serializers.py

@@ -3,8 +3,8 @@ from rest_framework import serializers
 from dcim.constants import CONNECTION_STATUS_CHOICES
 from dcim.constants import CONNECTION_STATUS_CHOICES
 from dcim.models import (
 from dcim.models import (
     Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
     Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
-    Interface, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, RearPort, RearPortTemplate,
-    Region, Site, VirtualChassis,
+    Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, Rack, RackGroup, RackRole,
+    RearPort, RearPortTemplate, Region, Site, VirtualChassis,
 )
 )
 from utilities.api import ChoiceField, WritableNestedSerializer
 from utilities.api import ChoiceField, WritableNestedSerializer
 
 
@@ -21,7 +21,9 @@ __all__ = [
     'NestedInterfaceSerializer',
     'NestedInterfaceSerializer',
     'NestedManufacturerSerializer',
     'NestedManufacturerSerializer',
     'NestedPlatformSerializer',
     'NestedPlatformSerializer',
+    'NestedPowerFeedSerializer',
     'NestedPowerOutletSerializer',
     'NestedPowerOutletSerializer',
+    'NestedPowerPanelSerializer',
     'NestedPowerPortSerializer',
     'NestedPowerPortSerializer',
     'NestedRackGroupSerializer',
     'NestedRackGroupSerializer',
     'NestedRackRoleSerializer',
     'NestedRackRoleSerializer',
@@ -40,10 +42,11 @@ __all__ = [
 
 
 class NestedRegionSerializer(WritableNestedSerializer):
 class NestedRegionSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
+    site_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'site_count']
 
 
 
 
 class NestedSiteSerializer(WritableNestedSerializer):
 class NestedSiteSerializer(WritableNestedSerializer):
@@ -60,26 +63,29 @@ class NestedSiteSerializer(WritableNestedSerializer):
 
 
 class NestedRackGroupSerializer(WritableNestedSerializer):
 class NestedRackGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
+    rack_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'rack_count']
 
 
 
 
 class NestedRackRoleSerializer(WritableNestedSerializer):
 class NestedRackRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
+    rack_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'rack_count']
 
 
 
 
 class NestedRackSerializer(WritableNestedSerializer):
 class NestedRackSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
+    device_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
-        fields = ['id', 'url', 'name', 'display_name']
+        fields = ['id', 'url', 'name', 'display_name', 'device_count']
 
 
 
 
 #
 #
@@ -88,19 +94,21 @@ class NestedRackSerializer(WritableNestedSerializer):
 
 
 class NestedManufacturerSerializer(WritableNestedSerializer):
 class NestedManufacturerSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
+    devicetype_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
 
 
 
 
 class NestedDeviceTypeSerializer(WritableNestedSerializer):
 class NestedDeviceTypeSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer(read_only=True)
     manufacturer = NestedManufacturerSerializer(read_only=True)
+    device_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
-        fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name']
+        fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
 
 
 
 
 class NestedRearPortTemplateSerializer(WritableNestedSerializer):
 class NestedRearPortTemplateSerializer(WritableNestedSerializer):
@@ -125,18 +133,22 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
 
 
 class NestedDeviceRoleSerializer(WritableNestedSerializer):
 class NestedDeviceRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
+    device_count = serializers.IntegerField(read_only=True)
+    virtualmachine_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
 
 
 
 
 class NestedPlatformSerializer(WritableNestedSerializer):
 class NestedPlatformSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
+    device_count = serializers.IntegerField(read_only=True)
+    virtualmachine_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
 
 
 
 
 class NestedDeviceSerializer(WritableNestedSerializer):
 class NestedDeviceSerializer(WritableNestedSerializer):
@@ -243,7 +255,29 @@ class NestedCableSerializer(serializers.ModelSerializer):
 class NestedVirtualChassisSerializer(WritableNestedSerializer):
 class NestedVirtualChassisSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     master = NestedDeviceSerializer()
     master = NestedDeviceSerializer()
+    member_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
-        fields = ['id', 'url', 'master']
+        fields = ['id', 'url', 'master', 'member_count']
+
+
+#
+# Power panels/feeds
+#
+
+class NestedPowerPanelSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
+    powerfeed_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = PowerPanel
+        fields = ['id', 'url', 'name', 'powerfeed_count']
+
+
+class NestedPowerFeedSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
+
+    class Meta:
+        model = PowerFeed
+        fields = ['id', 'url', 'name']

+ 140 - 38
netbox/dcim/api/serializers.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
 from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
 from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
@@ -7,8 +8,9 @@ from dcim.constants import *
 from dcim.models import (
 from dcim.models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
-    RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
+    Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
@@ -36,6 +38,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
             )
             )
         return None
         return None
 
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_connected_endpoint(self, obj):
     def get_connected_endpoint(self, obj):
         """
         """
         Return the appropriate serializer for the type of connected object.
         Return the appropriate serializer for the type of connected object.
@@ -56,10 +59,11 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
 
 
 class RegionSerializer(serializers.ModelSerializer):
 class RegionSerializer(serializers.ModelSerializer):
     parent = NestedRegionSerializer(required=False, allow_null=True)
     parent = NestedRegionSerializer(required=False, allow_null=True)
+    site_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
-        fields = ['id', 'name', 'slug', 'parent']
+        fields = ['id', 'name', 'slug', 'parent', 'site_count']
 
 
 
 
 class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
@@ -68,19 +72,20 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     time_zone = TimeZoneField(required=False)
     time_zone = TimeZoneField(required=False)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
-    count_prefixes = serializers.IntegerField(read_only=True)
-    count_vlans = serializers.IntegerField(read_only=True)
-    count_racks = serializers.IntegerField(read_only=True)
-    count_devices = serializers.IntegerField(read_only=True)
-    count_circuits = serializers.IntegerField(read_only=True)
+    circuit_count = serializers.IntegerField(read_only=True)
+    device_count = serializers.IntegerField(read_only=True)
+    prefix_count = serializers.IntegerField(read_only=True)
+    rack_count = serializers.IntegerField(read_only=True)
+    virtualmachine_count = serializers.IntegerField(read_only=True)
+    vlan_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = [
         fields = [
             'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
             'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
             'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
             'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
-            'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes',
-            'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
+            'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
+            'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
         ]
         ]
 
 
 
 
@@ -90,17 +95,19 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
 
 
 class RackGroupSerializer(ValidatedModelSerializer):
 class RackGroupSerializer(ValidatedModelSerializer):
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
+    rack_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
-        fields = ['id', 'name', 'slug', 'site']
+        fields = ['id', 'name', 'slug', 'site', 'rack_count']
 
 
 
 
 class RackRoleSerializer(ValidatedModelSerializer):
 class RackRoleSerializer(ValidatedModelSerializer):
+    rack_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
-        fields = ['id', 'name', 'slug', 'color']
+        fields = ['id', 'name', 'slug', 'color', 'rack_count']
 
 
 
 
 class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
@@ -113,13 +120,15 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
     width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False)
     width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False)
     outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_CHOICES, required=False)
     outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_CHOICES, required=False)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
+    device_count = serializers.IntegerField(read_only=True)
+    powerfeed_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
             'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
             'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
             'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
             'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
-            'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+            'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
         ]
         ]
         # Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This
         # Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This
         # prevents facility_id from being interpreted as a required field.
         # prevents facility_id from being interpreted as a required field.
@@ -166,23 +175,26 @@ class RackReservationSerializer(ValidatedModelSerializer):
 #
 #
 
 
 class ManufacturerSerializer(ValidatedModelSerializer):
 class ManufacturerSerializer(ValidatedModelSerializer):
+    devicetype_count = serializers.IntegerField(read_only=True)
+    inventoryitem_count = serializers.IntegerField(read_only=True)
+    platform_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'name', 'slug', 'devicetype_count', 'inventoryitem_count', 'platform_count']
 
 
 
 
 class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
     manufacturer = NestedManufacturerSerializer()
     manufacturer = NestedManufacturerSerializer()
     subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True)
     subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True)
-    instance_count = serializers.IntegerField(source='instances.count', read_only=True)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
+    device_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
             'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
-            'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'instance_count',
+            'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
         ]
         ]
 
 
 
 
@@ -207,24 +219,34 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
-        fields = ['id', 'device_type', 'name']
+        fields = ['id', 'device_type', 'name', 'maximum_draw', 'allocated_draw']
 
 
 
 
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
+    power_port = PowerPortTemplateSerializer(
+        required=False
+    )
+    feed_leg = ChoiceField(
+        choices=POWERFEED_LEG_CHOICES,
+        required=False,
+        allow_null=True
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
-        fields = ['id', 'device_type', 'name']
+        fields = ['id', 'device_type', 'name', 'power_port', 'feed_leg']
 
 
 
 
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
-    form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
+    type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
+    # TODO: Remove in v2.7 (backward-compatibility for form_factor)
+    form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
+        fields = ['id', 'device_type', 'name', 'type', 'form_factor', 'mgmt_only']
 
 
 
 
 class RearPortTemplateSerializer(ValidatedModelSerializer):
 class RearPortTemplateSerializer(ValidatedModelSerializer):
@@ -259,18 +281,25 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
 #
 #
 
 
 class DeviceRoleSerializer(ValidatedModelSerializer):
 class DeviceRoleSerializer(ValidatedModelSerializer):
+    device_count = serializers.IntegerField(read_only=True)
+    virtualmachine_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
-        fields = ['id', 'name', 'slug', 'color', 'vm_role']
+        fields = ['id', 'name', 'slug', 'color', 'vm_role', 'device_count', 'virtualmachine_count']
 
 
 
 
 class PlatformSerializer(ValidatedModelSerializer):
 class PlatformSerializer(ValidatedModelSerializer):
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
+    device_count = serializers.IntegerField(read_only=True)
+    virtualmachine_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args']
+        fields = [
+            'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'device_count',
+            'virtualmachine_count',
+        ]
 
 
 
 
 class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
@@ -313,6 +342,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
 
 
         return data
         return data
 
 
+    @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
     def get_parent_device(self, obj):
     def get_parent_device(self, obj):
         try:
         try:
             device_bay = obj.parent_bay
             device_bay = obj.parent_bay
@@ -335,6 +365,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
             'custom_fields', 'config_context', 'created', 'last_updated',
             'custom_fields', 'config_context', 'created', 'last_updated',
         ]
         ]
 
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_config_context(self, obj):
     def get_config_context(self, obj):
         return obj.get_config_context()
         return obj.get_config_context()
 
 
@@ -347,8 +378,8 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = [
         fields = [
-            'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
-            'tags',
+            'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
+            'cable', 'tags',
         ]
         ]
 
 
 
 
@@ -360,21 +391,33 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
         fields = [
         fields = [
-            'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
-            'tags',
+            'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
+            'cable', 'tags',
         ]
         ]
 
 
 
 
 class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
 class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
-    cable = NestedCableSerializer(read_only=True)
-    tags = TagListSerializerField(required=False)
+    power_port = NestedPowerPortSerializer(
+        required=False
+    )
+    feed_leg = ChoiceField(
+        choices=POWERFEED_LEG_CHOICES,
+        required=False,
+        allow_null=True
+    )
+    cable = NestedCableSerializer(
+        read_only=True
+    )
+    tags = TagListSerializerField(
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         fields = [
-            'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
-            'tags',
+            'id', 'device', 'name', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
+            'connected_endpoint', 'connection_status', 'cable', 'tags',
         ]
         ]
 
 
 
 
@@ -386,14 +429,16 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         fields = [
-            'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
-            'tags',
+            'id', 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
+            'connected_endpoint', 'connection_status', 'cable', 'tags',
         ]
         ]
 
 
 
 
 class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
 class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
-    form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
+    type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
+    # TODO: Remove in v2.7 (backward-compatibility for form_factor)
+    form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
     mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@@ -409,9 +454,9 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
-            'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
-            'tagged_vlans', 'tags', 'count_ipaddresses',
+            'id', 'device', 'name', 'type', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
+            'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
+            'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
         ]
         ]
 
 
     # TODO: This validation should be handled by Interface.clean()
     # TODO: This validation should be handled by Interface.clean()
@@ -476,7 +521,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
-        fields = ['id', 'device', 'name', 'installed_device', 'tags']
+        fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
 
 
 
 
 #
 #
@@ -536,9 +581,11 @@ class CableSerializer(ValidatedModelSerializer):
 
 
         return data
         return data
 
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_termination_a(self, obj):
     def get_termination_a(self, obj):
         return self._get_termination(obj, 'a')
         return self._get_termination(obj, 'a')
 
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_termination_b(self, obj):
     def get_termination_b(self, obj):
         return self._get_termination(obj, 'b')
         return self._get_termination(obj, 'b')
 
 
@@ -569,6 +616,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
         model = Interface
         model = Interface
         fields = ['interface_a', 'interface_b', 'connection_status']
         fields = ['interface_a', 'interface_b', 'connection_status']
 
 
+    @swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer)
     def get_interface_a(self, obj):
     def get_interface_a(self, obj):
         context = {'request': self.context['request']}
         context = {'request': self.context['request']}
         return NestedInterfaceSerializer(instance=obj, context=context).data
         return NestedInterfaceSerializer(instance=obj, context=context).data
@@ -581,7 +629,61 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
 class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
 class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
     master = NestedDeviceSerializer()
     master = NestedDeviceSerializer()
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
+    member_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
-        fields = ['id', 'master', 'domain', 'tags']
+        fields = ['id', 'master', 'domain', 'tags', 'member_count']
+
+
+#
+# Power panels
+#
+
+class PowerPanelSerializer(ValidatedModelSerializer):
+    site = NestedSiteSerializer()
+    rack_group = NestedRackGroupSerializer(
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    powerfeed_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = PowerPanel
+        fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count']
+
+
+class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
+    power_panel = NestedPowerPanelSerializer()
+    rack = NestedRackSerializer(
+        required=False,
+        allow_null=True,
+        default=None
+    )
+    type = ChoiceField(
+        choices=POWERFEED_TYPE_CHOICES,
+        default=POWERFEED_TYPE_PRIMARY
+    )
+    status = ChoiceField(
+        choices=POWERFEED_STATUS_CHOICES,
+        default=POWERFEED_STATUS_ACTIVE
+    )
+    supply = ChoiceField(
+        choices=POWERFEED_SUPPLY_CHOICES,
+        default=POWERFEED_SUPPLY_AC
+    )
+    phase = ChoiceField(
+        choices=POWERFEED_PHASE_CHOICES,
+        default=POWERFEED_PHASE_SINGLE
+    )
+    tags = TagListSerializerField(
+        required=False
+    )
+
+    class Meta:
+        model = PowerFeed
+        fields = [
+            'id', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
+            'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]

+ 4 - 0
netbox/dcim/api/urls.py

@@ -68,6 +68,10 @@ router.register(r'cables', views.CableViewSet)
 # Virtual chassis
 # Virtual chassis
 router.register(r'virtual-chassis', views.VirtualChassisViewSet)
 router.register(r'virtual-chassis', views.VirtualChassisViewSet)
 
 
+# Power
+router.register(r'power-panels', views.PowerPanelViewSet)
+router.register(r'power-feeds', views.PowerFeedViewSet)
+
 # Miscellaneous
 # Miscellaneous
 router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
 
 

+ 101 - 21
netbox/dcim/api/views.py

@@ -1,7 +1,7 @@
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django.conf import settings
 from django.conf import settings
-from django.db.models import F
+from django.db.models import Count, F
 from django.http import HttpResponseForbidden
 from django.http import HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
 from drf_yasg import openapi
@@ -12,19 +12,24 @@ from rest_framework.mixins import ListModelMixin
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.viewsets import GenericViewSet, ViewSet
 from rest_framework.viewsets import GenericViewSet, ViewSet
 
 
+from circuits.models import Circuit
 from dcim import filters
 from dcim import filters
 from dcim.models import (
 from dcim.models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
-    RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
+    Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
+from ipam.models import Prefix, VLAN
 from utilities.api import (
 from utilities.api import (
     get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
     get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
 )
 )
+from utilities.utils import get_subquery
+from virtualization.models import VirtualMachine
 from . import serializers
 from . import serializers
 from .exceptions import MissingFilterException
 from .exceptions import MissingFilterException
 
 
@@ -41,8 +46,10 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
         (DeviceType, ['subdevice_role']),
         (DeviceType, ['subdevice_role']),
         (FrontPort, ['type']),
         (FrontPort, ['type']),
         (FrontPortTemplate, ['type']),
         (FrontPortTemplate, ['type']),
-        (Interface, ['form_factor', 'mode']),
-        (InterfaceTemplate, ['form_factor']),
+        (Interface, ['type', 'mode']),
+        (InterfaceTemplate, ['type']),
+        (PowerOutlet, ['feed_leg']),
+        (PowerOutletTemplate, ['feed_leg']),
         (PowerPort, ['connection_status']),
         (PowerPort, ['connection_status']),
         (Rack, ['outer_unit', 'status', 'type', 'width']),
         (Rack, ['outer_unit', 'status', 'type', 'width']),
         (RearPort, ['type']),
         (RearPort, ['type']),
@@ -90,7 +97,9 @@ class CableTraceMixin(object):
 #
 #
 
 
 class RegionViewSet(ModelViewSet):
 class RegionViewSet(ModelViewSet):
-    queryset = Region.objects.all()
+    queryset = Region.objects.annotate(
+        site_count=Count('sites')
+    )
     serializer_class = serializers.RegionSerializer
     serializer_class = serializers.RegionSerializer
     filterset_class = filters.RegionFilter
     filterset_class = filters.RegionFilter
 
 
@@ -100,7 +109,18 @@ class RegionViewSet(ModelViewSet):
 #
 #
 
 
 class SiteViewSet(CustomFieldModelViewSet):
 class SiteViewSet(CustomFieldModelViewSet):
-    queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags')
+    queryset = Site.objects.select_related(
+        'region', 'tenant'
+    ).prefetch_related(
+        'tags'
+    ).annotate(
+        device_count=get_subquery(Device, 'site'),
+        rack_count=get_subquery(Rack, 'site'),
+        prefix_count=get_subquery(Prefix, 'site'),
+        vlan_count=get_subquery(VLAN, 'site'),
+        circuit_count=get_subquery(Circuit, 'terminations__site'),
+        virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
+    )
     serializer_class = serializers.SiteSerializer
     serializer_class = serializers.SiteSerializer
     filterset_class = filters.SiteFilter
     filterset_class = filters.SiteFilter
 
 
@@ -120,7 +140,9 @@ class SiteViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class RackGroupViewSet(ModelViewSet):
 class RackGroupViewSet(ModelViewSet):
-    queryset = RackGroup.objects.select_related('site')
+    queryset = RackGroup.objects.select_related('site').annotate(
+        rack_count=Count('racks')
+    )
     serializer_class = serializers.RackGroupSerializer
     serializer_class = serializers.RackGroupSerializer
     filterset_class = filters.RackGroupFilter
     filterset_class = filters.RackGroupFilter
 
 
@@ -130,7 +152,9 @@ class RackGroupViewSet(ModelViewSet):
 #
 #
 
 
 class RackRoleViewSet(ModelViewSet):
 class RackRoleViewSet(ModelViewSet):
-    queryset = RackRole.objects.all()
+    queryset = RackRole.objects.annotate(
+        rack_count=Count('racks')
+    )
     serializer_class = serializers.RackRoleSerializer
     serializer_class = serializers.RackRoleSerializer
     filterset_class = filters.RackRoleFilter
     filterset_class = filters.RackRoleFilter
 
 
@@ -140,7 +164,14 @@ class RackRoleViewSet(ModelViewSet):
 #
 #
 
 
 class RackViewSet(CustomFieldModelViewSet):
 class RackViewSet(CustomFieldModelViewSet):
-    queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags')
+    queryset = Rack.objects.select_related(
+        'site', 'group__site', 'role', 'tenant'
+    ).prefetch_related(
+        'tags'
+    ).annotate(
+        device_count=get_subquery(Device, 'rack'),
+        powerfeed_count=get_subquery(PowerFeed, 'rack')
+    )
     serializer_class = serializers.RackSerializer
     serializer_class = serializers.RackSerializer
     filterset_class = filters.RackFilter
     filterset_class = filters.RackFilter
 
 
@@ -189,7 +220,11 @@ class RackReservationViewSet(ModelViewSet):
 #
 #
 
 
 class ManufacturerViewSet(ModelViewSet):
 class ManufacturerViewSet(ModelViewSet):
-    queryset = Manufacturer.objects.all()
+    queryset = Manufacturer.objects.annotate(
+        devicetype_count=get_subquery(DeviceType, 'manufacturer'),
+        inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
+        platform_count=get_subquery(Platform, 'manufacturer')
+    )
     serializer_class = serializers.ManufacturerSerializer
     serializer_class = serializers.ManufacturerSerializer
     filterset_class = filters.ManufacturerFilter
     filterset_class = filters.ManufacturerFilter
 
 
@@ -199,7 +234,9 @@ class ManufacturerViewSet(ModelViewSet):
 #
 #
 
 
 class DeviceTypeViewSet(CustomFieldModelViewSet):
 class DeviceTypeViewSet(CustomFieldModelViewSet):
-    queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags')
+    queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags').annotate(
+        device_count=Count('instances')
+    )
     serializer_class = serializers.DeviceTypeSerializer
     serializer_class = serializers.DeviceTypeSerializer
     filterset_class = filters.DeviceTypeFilter
     filterset_class = filters.DeviceTypeFilter
 
 
@@ -261,7 +298,10 @@ class DeviceBayTemplateViewSet(ModelViewSet):
 #
 #
 
 
 class DeviceRoleViewSet(ModelViewSet):
 class DeviceRoleViewSet(ModelViewSet):
-    queryset = DeviceRole.objects.all()
+    queryset = DeviceRole.objects.annotate(
+        device_count=get_subquery(Device, 'device_role'),
+        virtualmachine_count=get_subquery(VirtualMachine, 'role')
+    )
     serializer_class = serializers.DeviceRoleSerializer
     serializer_class = serializers.DeviceRoleSerializer
     filterset_class = filters.DeviceRoleFilter
     filterset_class = filters.DeviceRoleFilter
 
 
@@ -271,7 +311,10 @@ class DeviceRoleViewSet(ModelViewSet):
 #
 #
 
 
 class PlatformViewSet(ModelViewSet):
 class PlatformViewSet(ModelViewSet):
-    queryset = Platform.objects.all()
+    queryset = Platform.objects.annotate(
+        device_count=get_subquery(Device, 'platform'),
+        virtualmachine_count=get_subquery(VirtualMachine, 'platform')
+    )
     serializer_class = serializers.PlatformSerializer
     serializer_class = serializers.PlatformSerializer
     filterset_class = filters.PlatformFilter
     filterset_class = filters.PlatformFilter
 
 
@@ -291,16 +334,23 @@ class DeviceViewSet(CustomFieldModelViewSet):
 
 
     def get_serializer_class(self):
     def get_serializer_class(self):
         """
         """
-        Include rendered config context when retrieving a single Device.
+        Select the specific serializer based on the request context.
+
+        If the `brief` query param equates to True, return the NestedDeviceSerializer
+
+        If the `exclude` query param includes `config_context` as a value, return the DeviceSerializer
+
+        Else, return the DeviceWithConfigContextSerializer
         """
         """
-        if self.action == 'retrieve':
-            return serializers.DeviceWithConfigContextSerializer
 
 
         request = self.get_serializer_context()['request']
         request = self.get_serializer_context()['request']
         if request.query_params.get('brief', False):
         if request.query_params.get('brief', False):
             return serializers.NestedDeviceSerializer
             return serializers.NestedDeviceSerializer
 
 
-        return serializers.DeviceSerializer
+        elif 'config_context' in request.query_params.get('exclude', []):
+            return serializers.DeviceSerializer
+
+        return serializers.DeviceWithConfigContextSerializer
 
 
     @action(detail=True, url_path='napalm')
     @action(detail=True, url_path='napalm')
     def napalm(self, request, pk):
     def napalm(self, request, pk):
@@ -400,7 +450,7 @@ class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
 
 
 class PowerPortViewSet(CableTraceMixin, ModelViewSet):
 class PowerPortViewSet(CableTraceMixin, ModelViewSet):
     queryset = PowerPort.objects.select_related(
     queryset = PowerPort.objects.select_related(
-        'device', 'connected_endpoint__device', 'cable'
+        'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable'
     ).prefetch_related(
     ).prefetch_related(
         'tags'
         'tags'
     )
     )
@@ -490,7 +540,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
     queryset = PowerPort.objects.select_related(
     queryset = PowerPort.objects.select_related(
         'device', 'connected_endpoint__device'
         'device', 'connected_endpoint__device'
     ).filter(
     ).filter(
-        connected_endpoint__isnull=False
+        _connected_poweroutlet__isnull=False
     )
     )
     serializer_class = serializers.PowerPortSerializer
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filters.PowerConnectionFilter
     filterset_class = filters.PowerConnectionFilter
@@ -525,10 +575,40 @@ class CableViewSet(ModelViewSet):
 #
 #
 
 
 class VirtualChassisViewSet(ModelViewSet):
 class VirtualChassisViewSet(ModelViewSet):
-    queryset = VirtualChassis.objects.prefetch_related('tags')
+    queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
+        member_count=Count('members')
+    )
     serializer_class = serializers.VirtualChassisSerializer
     serializer_class = serializers.VirtualChassisSerializer
 
 
 
 
+#
+# Power panels
+#
+
+class PowerPanelViewSet(ModelViewSet):
+    queryset = PowerPanel.objects.select_related(
+        'site', 'rack_group'
+    ).annotate(
+        powerfeed_count=Count('powerfeeds')
+    )
+    serializer_class = serializers.PowerPanelSerializer
+    filterset_class = filters.PowerPanelFilter
+
+
+#
+# Power feeds
+#
+
+class PowerFeedViewSet(CustomFieldModelViewSet):
+    queryset = PowerFeed.objects.select_related(
+        'power_panel', 'rack'
+    ).prefetch_related(
+        'tags'
+    )
+    serializer_class = serializers.PowerFeedSerializer
+    filterset_class = filters.PowerFeedFilter
+
+
 #
 #
 # Miscellaneous
 # Miscellaneous
 #
 #

+ 170 - 132
netbox/dcim/constants.py

@@ -66,204 +66,204 @@ IFACE_ORDERING_CHOICES = [
     [IFACE_ORDERING_NAME, 'Name (alphabetically)']
     [IFACE_ORDERING_NAME, 'Name (alphabetically)']
 ]
 ]
 
 
-# Interface form factors
+# Interface types
 # Virtual
 # Virtual
-IFACE_FF_VIRTUAL = 0
-IFACE_FF_LAG = 200
+IFACE_TYPE_VIRTUAL = 0
+IFACE_TYPE_LAG = 200
 # Ethernet
 # Ethernet
-IFACE_FF_100ME_FIXED = 800
-IFACE_FF_1GE_FIXED = 1000
-IFACE_FF_1GE_GBIC = 1050
-IFACE_FF_1GE_SFP = 1100
-IFACE_FF_2GE_FIXED = 1120
-IFACE_FF_5GE_FIXED = 1130
-IFACE_FF_10GE_FIXED = 1150
-IFACE_FF_10GE_CX4 = 1170
-IFACE_FF_10GE_SFP_PLUS = 1200
-IFACE_FF_10GE_XFP = 1300
-IFACE_FF_10GE_XENPAK = 1310
-IFACE_FF_10GE_X2 = 1320
-IFACE_FF_25GE_SFP28 = 1350
-IFACE_FF_40GE_QSFP_PLUS = 1400
-IFACE_FF_50GE_QSFP28 = 1420
-IFACE_FF_100GE_CFP = 1500
-IFACE_FF_100GE_CFP2 = 1510
-IFACE_FF_100GE_CFP4 = 1520
-IFACE_FF_100GE_CPAK = 1550
-IFACE_FF_100GE_QSFP28 = 1600
-IFACE_FF_200GE_CFP2 = 1650
-IFACE_FF_200GE_QSFP56 = 1700
-IFACE_FF_400GE_QSFP_DD = 1750
+IFACE_TYPE_100ME_FIXED = 800
+IFACE_TYPE_1GE_FIXED = 1000
+IFACE_TYPE_1GE_GBIC = 1050
+IFACE_TYPE_1GE_SFP = 1100
+IFACE_TYPE_2GE_FIXED = 1120
+IFACE_TYPE_5GE_FIXED = 1130
+IFACE_TYPE_10GE_FIXED = 1150
+IFACE_TYPE_10GE_CX4 = 1170
+IFACE_TYPE_10GE_SFP_PLUS = 1200
+IFACE_TYPE_10GE_XFP = 1300
+IFACE_TYPE_10GE_XENPAK = 1310
+IFACE_TYPE_10GE_X2 = 1320
+IFACE_TYPE_25GE_SFP28 = 1350
+IFACE_TYPE_40GE_QSFP_PLUS = 1400
+IFACE_TYPE_50GE_QSFP28 = 1420
+IFACE_TYPE_100GE_CFP = 1500
+IFACE_TYPE_100GE_CFP2 = 1510
+IFACE_TYPE_100GE_CFP4 = 1520
+IFACE_TYPE_100GE_CPAK = 1550
+IFACE_TYPE_100GE_QSFP28 = 1600
+IFACE_TYPE_200GE_CFP2 = 1650
+IFACE_TYPE_200GE_QSFP56 = 1700
+IFACE_TYPE_400GE_QSFP_DD = 1750
 # Wireless
 # Wireless
-IFACE_FF_80211A = 2600
-IFACE_FF_80211G = 2610
-IFACE_FF_80211N = 2620
-IFACE_FF_80211AC = 2630
-IFACE_FF_80211AD = 2640
+IFACE_TYPE_80211A = 2600
+IFACE_TYPE_80211G = 2610
+IFACE_TYPE_80211N = 2620
+IFACE_TYPE_80211AC = 2630
+IFACE_TYPE_80211AD = 2640
 # Cellular
 # Cellular
-IFACE_FF_GSM = 2810
-IFACE_FF_CDMA = 2820
-IFACE_FF_LTE = 2830
+IFACE_TYPE_GSM = 2810
+IFACE_TYPE_CDMA = 2820
+IFACE_TYPE_LTE = 2830
 # SONET
 # SONET
-IFACE_FF_SONET_OC3 = 6100
-IFACE_FF_SONET_OC12 = 6200
-IFACE_FF_SONET_OC48 = 6300
-IFACE_FF_SONET_OC192 = 6400
-IFACE_FF_SONET_OC768 = 6500
-IFACE_FF_SONET_OC1920 = 6600
-IFACE_FF_SONET_OC3840 = 6700
+IFACE_TYPE_SONET_OC3 = 6100
+IFACE_TYPE_SONET_OC12 = 6200
+IFACE_TYPE_SONET_OC48 = 6300
+IFACE_TYPE_SONET_OC192 = 6400
+IFACE_TYPE_SONET_OC768 = 6500
+IFACE_TYPE_SONET_OC1920 = 6600
+IFACE_TYPE_SONET_OC3840 = 6700
 # Fibrechannel
 # Fibrechannel
-IFACE_FF_1GFC_SFP = 3010
-IFACE_FF_2GFC_SFP = 3020
-IFACE_FF_4GFC_SFP = 3040
-IFACE_FF_8GFC_SFP_PLUS = 3080
-IFACE_FF_16GFC_SFP_PLUS = 3160
-IFACE_FF_32GFC_SFP28 = 3320
-IFACE_FF_128GFC_QSFP28 = 3400
+IFACE_TYPE_1GFC_SFP = 3010
+IFACE_TYPE_2GFC_SFP = 3020
+IFACE_TYPE_4GFC_SFP = 3040
+IFACE_TYPE_8GFC_SFP_PLUS = 3080
+IFACE_TYPE_16GFC_SFP_PLUS = 3160
+IFACE_TYPE_32GFC_SFP28 = 3320
+IFACE_TYPE_128GFC_QSFP28 = 3400
 # Serial
 # Serial
-IFACE_FF_T1 = 4000
-IFACE_FF_E1 = 4010
-IFACE_FF_T3 = 4040
-IFACE_FF_E3 = 4050
+IFACE_TYPE_T1 = 4000
+IFACE_TYPE_E1 = 4010
+IFACE_TYPE_T3 = 4040
+IFACE_TYPE_E3 = 4050
 # Stacking
 # Stacking
-IFACE_FF_STACKWISE = 5000
-IFACE_FF_STACKWISE_PLUS = 5050
-IFACE_FF_FLEXSTACK = 5100
-IFACE_FF_FLEXSTACK_PLUS = 5150
-IFACE_FF_JUNIPER_VCP = 5200
-IFACE_FF_SUMMITSTACK = 5300
-IFACE_FF_SUMMITSTACK128 = 5310
-IFACE_FF_SUMMITSTACK256 = 5320
-IFACE_FF_SUMMITSTACK512 = 5330
+IFACE_TYPE_STACKWISE = 5000
+IFACE_TYPE_STACKWISE_PLUS = 5050
+IFACE_TYPE_FLEXSTACK = 5100
+IFACE_TYPE_FLEXSTACK_PLUS = 5150
+IFACE_TYPE_JUNIPER_VCP = 5200
+IFACE_TYPE_SUMMITSTACK = 5300
+IFACE_TYPE_SUMMITSTACK128 = 5310
+IFACE_TYPE_SUMMITSTACK256 = 5320
+IFACE_TYPE_SUMMITSTACK512 = 5330
 
 
 # Other
 # Other
-IFACE_FF_OTHER = 32767
+IFACE_TYPE_OTHER = 32767
 
 
-IFACE_FF_CHOICES = [
+IFACE_TYPE_CHOICES = [
     [
     [
         'Virtual interfaces',
         'Virtual interfaces',
         [
         [
-            [IFACE_FF_VIRTUAL, 'Virtual'],
-            [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
+            [IFACE_TYPE_VIRTUAL, 'Virtual'],
+            [IFACE_TYPE_LAG, 'Link Aggregation Group (LAG)'],
         ],
         ],
     ],
     ],
     [
     [
         'Ethernet (fixed)',
         'Ethernet (fixed)',
         [
         [
-            [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
-            [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
-            [IFACE_FF_2GE_FIXED, '2.5GBASE-T (2.5GE)'],
-            [IFACE_FF_5GE_FIXED, '5GBASE-T (5GE)'],
-            [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
-            [IFACE_FF_10GE_CX4, '10GBASE-CX4 (10GE)'],
+            [IFACE_TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'],
+            [IFACE_TYPE_1GE_FIXED, '1000BASE-T (1GE)'],
+            [IFACE_TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'],
+            [IFACE_TYPE_5GE_FIXED, '5GBASE-T (5GE)'],
+            [IFACE_TYPE_10GE_FIXED, '10GBASE-T (10GE)'],
+            [IFACE_TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'],
         ]
         ]
     ],
     ],
     [
     [
         'Ethernet (modular)',
         'Ethernet (modular)',
         [
         [
-            [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'],
-            [IFACE_FF_1GE_SFP, 'SFP (1GE)'],
-            [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'],
-            [IFACE_FF_10GE_XFP, 'XFP (10GE)'],
-            [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'],
-            [IFACE_FF_10GE_X2, 'X2 (10GE)'],
-            [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
-            [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
-            [IFACE_FF_50GE_QSFP28, 'QSFP28 (50GE)'],
-            [IFACE_FF_100GE_CFP, 'CFP (100GE)'],
-            [IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
-            [IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'],
-            [IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
-            [IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
-            [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
-            [IFACE_FF_200GE_QSFP56, 'QSFP56 (200GE)'],
-            [IFACE_FF_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
+            [IFACE_TYPE_1GE_GBIC, 'GBIC (1GE)'],
+            [IFACE_TYPE_1GE_SFP, 'SFP (1GE)'],
+            [IFACE_TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'],
+            [IFACE_TYPE_10GE_XFP, 'XFP (10GE)'],
+            [IFACE_TYPE_10GE_XENPAK, 'XENPAK (10GE)'],
+            [IFACE_TYPE_10GE_X2, 'X2 (10GE)'],
+            [IFACE_TYPE_25GE_SFP28, 'SFP28 (25GE)'],
+            [IFACE_TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
+            [IFACE_TYPE_50GE_QSFP28, 'QSFP28 (50GE)'],
+            [IFACE_TYPE_100GE_CFP, 'CFP (100GE)'],
+            [IFACE_TYPE_100GE_CFP2, 'CFP2 (100GE)'],
+            [IFACE_TYPE_200GE_CFP2, 'CFP2 (200GE)'],
+            [IFACE_TYPE_100GE_CFP4, 'CFP4 (100GE)'],
+            [IFACE_TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'],
+            [IFACE_TYPE_100GE_QSFP28, 'QSFP28 (100GE)'],
+            [IFACE_TYPE_200GE_QSFP56, 'QSFP56 (200GE)'],
+            [IFACE_TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
         ]
         ]
     ],
     ],
     [
     [
         'Wireless',
         'Wireless',
         [
         [
-            [IFACE_FF_80211A, 'IEEE 802.11a'],
-            [IFACE_FF_80211G, 'IEEE 802.11b/g'],
-            [IFACE_FF_80211N, 'IEEE 802.11n'],
-            [IFACE_FF_80211AC, 'IEEE 802.11ac'],
-            [IFACE_FF_80211AD, 'IEEE 802.11ad'],
+            [IFACE_TYPE_80211A, 'IEEE 802.11a'],
+            [IFACE_TYPE_80211G, 'IEEE 802.11b/g'],
+            [IFACE_TYPE_80211N, 'IEEE 802.11n'],
+            [IFACE_TYPE_80211AC, 'IEEE 802.11ac'],
+            [IFACE_TYPE_80211AD, 'IEEE 802.11ad'],
         ]
         ]
     ],
     ],
     [
     [
         'Cellular',
         'Cellular',
         [
         [
-            [IFACE_FF_GSM, 'GSM'],
-            [IFACE_FF_CDMA, 'CDMA'],
-            [IFACE_FF_LTE, 'LTE'],
+            [IFACE_TYPE_GSM, 'GSM'],
+            [IFACE_TYPE_CDMA, 'CDMA'],
+            [IFACE_TYPE_LTE, 'LTE'],
         ]
         ]
     ],
     ],
     [
     [
         'SONET',
         'SONET',
         [
         [
-            [IFACE_FF_SONET_OC3, 'OC-3/STM-1'],
-            [IFACE_FF_SONET_OC12, 'OC-12/STM-4'],
-            [IFACE_FF_SONET_OC48, 'OC-48/STM-16'],
-            [IFACE_FF_SONET_OC192, 'OC-192/STM-64'],
-            [IFACE_FF_SONET_OC768, 'OC-768/STM-256'],
-            [IFACE_FF_SONET_OC1920, 'OC-1920/STM-640'],
-            [IFACE_FF_SONET_OC3840, 'OC-3840/STM-1234'],
+            [IFACE_TYPE_SONET_OC3, 'OC-3/STM-1'],
+            [IFACE_TYPE_SONET_OC12, 'OC-12/STM-4'],
+            [IFACE_TYPE_SONET_OC48, 'OC-48/STM-16'],
+            [IFACE_TYPE_SONET_OC192, 'OC-192/STM-64'],
+            [IFACE_TYPE_SONET_OC768, 'OC-768/STM-256'],
+            [IFACE_TYPE_SONET_OC1920, 'OC-1920/STM-640'],
+            [IFACE_TYPE_SONET_OC3840, 'OC-3840/STM-1234'],
         ]
         ]
     ],
     ],
     [
     [
         'FibreChannel',
         'FibreChannel',
         [
         [
-            [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'],
-            [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'],
-            [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
-            [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
-            [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
-            [IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'],
-            [IFACE_FF_128GFC_QSFP28, 'QSFP28 (128GFC)'],
+            [IFACE_TYPE_1GFC_SFP, 'SFP (1GFC)'],
+            [IFACE_TYPE_2GFC_SFP, 'SFP (2GFC)'],
+            [IFACE_TYPE_4GFC_SFP, 'SFP (4GFC)'],
+            [IFACE_TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
+            [IFACE_TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
+            [IFACE_TYPE_32GFC_SFP28, 'SFP28 (32GFC)'],
+            [IFACE_TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'],
         ]
         ]
     ],
     ],
     [
     [
         'Serial',
         'Serial',
         [
         [
-            [IFACE_FF_T1, 'T1 (1.544 Mbps)'],
-            [IFACE_FF_E1, 'E1 (2.048 Mbps)'],
-            [IFACE_FF_T3, 'T3 (45 Mbps)'],
-            [IFACE_FF_E3, 'E3 (34 Mbps)'],
+            [IFACE_TYPE_T1, 'T1 (1.544 Mbps)'],
+            [IFACE_TYPE_E1, 'E1 (2.048 Mbps)'],
+            [IFACE_TYPE_T3, 'T3 (45 Mbps)'],
+            [IFACE_TYPE_E3, 'E3 (34 Mbps)'],
         ]
         ]
     ],
     ],
     [
     [
         'Stacking',
         'Stacking',
         [
         [
-            [IFACE_FF_STACKWISE, 'Cisco StackWise'],
-            [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
-            [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
-            [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
-            [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
-            [IFACE_FF_SUMMITSTACK, 'Extreme SummitStack'],
-            [IFACE_FF_SUMMITSTACK128, 'Extreme SummitStack-128'],
-            [IFACE_FF_SUMMITSTACK256, 'Extreme SummitStack-256'],
-            [IFACE_FF_SUMMITSTACK512, 'Extreme SummitStack-512'],
+            [IFACE_TYPE_STACKWISE, 'Cisco StackWise'],
+            [IFACE_TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'],
+            [IFACE_TYPE_FLEXSTACK, 'Cisco FlexStack'],
+            [IFACE_TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
+            [IFACE_TYPE_JUNIPER_VCP, 'Juniper VCP'],
+            [IFACE_TYPE_SUMMITSTACK, 'Extreme SummitStack'],
+            [IFACE_TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'],
+            [IFACE_TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'],
+            [IFACE_TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'],
         ]
         ]
     ],
     ],
     [
     [
         'Other',
         'Other',
         [
         [
-            [IFACE_FF_OTHER, 'Other'],
+            [IFACE_TYPE_OTHER, 'Other'],
         ]
         ]
     ],
     ],
 ]
 ]
 
 
 VIRTUAL_IFACE_TYPES = [
 VIRTUAL_IFACE_TYPES = [
-    IFACE_FF_VIRTUAL,
-    IFACE_FF_LAG,
+    IFACE_TYPE_VIRTUAL,
+    IFACE_TYPE_LAG,
 ]
 ]
 
 
 WIRELESS_IFACE_TYPES = [
 WIRELESS_IFACE_TYPES = [
-    IFACE_FF_80211A,
-    IFACE_FF_80211G,
-    IFACE_FF_80211N,
-    IFACE_FF_80211AC,
-    IFACE_FF_80211AD,
+    IFACE_TYPE_80211A,
+    IFACE_TYPE_80211G,
+    IFACE_TYPE_80211N,
+    IFACE_TYPE_80211AC,
+    IFACE_TYPE_80211AD,
 ]
 ]
 
 
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
@@ -429,7 +429,7 @@ CABLE_TERMINATION_TYPE_CHOICES = {
 COMPATIBLE_TERMINATION_TYPES = {
 COMPATIBLE_TERMINATION_TYPES = {
     'consoleport': ['consoleserverport', 'frontport', 'rearport'],
     'consoleport': ['consoleserverport', 'frontport', 'rearport'],
     'consoleserverport': ['consoleport', 'frontport', 'rearport'],
     'consoleserverport': ['consoleport', 'frontport', 'rearport'],
-    'powerport': ['poweroutlet'],
+    'powerport': ['poweroutlet', 'powerfeed'],
     'poweroutlet': ['powerport'],
     'poweroutlet': ['powerport'],
     'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
     'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
     'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
     'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
@@ -452,3 +452,41 @@ RACK_DIMENSION_UNIT_CHOICES = (
     (LENGTH_UNIT_MILLIMETER, 'Millimeters'),
     (LENGTH_UNIT_MILLIMETER, 'Millimeters'),
     (LENGTH_UNIT_INCH, 'Inches'),
     (LENGTH_UNIT_INCH, 'Inches'),
 )
 )
+
+# Power feeds
+POWERFEED_TYPE_PRIMARY = 1
+POWERFEED_TYPE_REDUNDANT = 2
+POWERFEED_TYPE_CHOICES = (
+    (POWERFEED_TYPE_PRIMARY, 'Primary'),
+    (POWERFEED_TYPE_REDUNDANT, 'Redundant'),
+)
+POWERFEED_SUPPLY_AC = 1
+POWERFEED_SUPPLY_DC = 2
+POWERFEED_SUPPLY_CHOICES = (
+    (POWERFEED_SUPPLY_AC, 'AC'),
+    (POWERFEED_SUPPLY_DC, 'DC'),
+)
+POWERFEED_PHASE_SINGLE = 1
+POWERFEED_PHASE_3PHASE = 3
+POWERFEED_PHASE_CHOICES = (
+    (POWERFEED_PHASE_SINGLE, 'Single phase'),
+    (POWERFEED_PHASE_3PHASE, 'Three-phase'),
+)
+POWERFEED_STATUS_OFFLINE = 0
+POWERFEED_STATUS_ACTIVE = 1
+POWERFEED_STATUS_PLANNED = 2
+POWERFEED_STATUS_FAILED = 4
+POWERFEED_STATUS_CHOICES = (
+    (POWERFEED_STATUS_ACTIVE, 'Active'),
+    (POWERFEED_STATUS_OFFLINE, 'Offline'),
+    (POWERFEED_STATUS_PLANNED, 'Planned'),
+    (POWERFEED_STATUS_FAILED, 'Failed'),
+)
+POWERFEED_LEG_A = 1
+POWERFEED_LEG_B = 2
+POWERFEED_LEG_C = 3
+POWERFEED_LEG_CHOICES = (
+    (POWERFEED_LEG_A, 'A'),
+    (POWERFEED_LEG_B, 'B'),
+    (POWERFEED_LEG_C, 'C'),
+)

+ 137 - 57
netbox/dcim/filters.py

@@ -9,16 +9,15 @@ from extras.filters import CustomFieldFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.constants import COLOR_CHOICES
 from utilities.constants import COLOR_CHOICES
-from utilities.filters import (
-    NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
-)
+from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from .constants import *
 from .constants import *
 from .models import (
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
-    RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
+    InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 
 
 
 
@@ -36,7 +35,7 @@ class RegionFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = Region
         model = Region
-        fields = ['name', 'slug']
+        fields = ['id', 'name', 'slug']
 
 
 
 
 class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
 class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
@@ -67,7 +66,10 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
 
 
     class Meta:
     class Meta:
         model = Site
         model = Site
-        fields = ['q', 'name', 'slug', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email']
+        fields = [
+            'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone',
+            'contact_email',
+        ]
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -104,14 +106,14 @@ class RackGroupFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = RackGroup
         model = RackGroup
-        fields = ['site_id', 'name', 'slug']
+        fields = ['id', 'name', 'slug']
 
 
 
 
 class RackRoleFilter(NameSlugSearchFilterSet):
 class RackRoleFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = RackRole
         model = RackRole
-        fields = ['name', 'slug', 'color']
+        fields = ['id', 'name', 'slug', 'color']
 
 
 
 
 class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
 class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
@@ -123,7 +125,6 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
-    facility_id = NullableCharFieldFilter()
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -158,14 +159,13 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Role (slug)',
         label='Role (slug)',
     )
     )
-    asset_tag = NullableCharFieldFilter()
     tag = TagFilter()
     tag = TagFilter()
 
 
     class Meta:
     class Meta:
         model = Rack
         model = Rack
         fields = [
         fields = [
-            'name', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
-            'outer_unit',
+            'id', 'name', 'facility_id', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
+            'outer_width', 'outer_depth', 'outer_unit',
         ]
         ]
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
@@ -245,7 +245,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = Manufacturer
         model = Manufacturer
-        fields = ['name', 'slug']
+        fields = ['id', 'name', 'slug']
 
 
 
 
 class DeviceTypeFilter(CustomFieldFilterSet):
 class DeviceTypeFilter(CustomFieldFilterSet):
@@ -343,63 +343,63 @@ class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
-        fields = ['name']
+        fields = ['id', 'name']
 
 
 
 
 class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet):
 class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
-        fields = ['name']
+        fields = ['id', 'name']
 
 
 
 
 class PowerPortTemplateFilter(DeviceTypeComponentFilterSet):
 class PowerPortTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
-        fields = ['name']
+        fields = ['id', 'name', 'maximum_draw', 'allocated_draw']
 
 
 
 
 class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet):
 class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
-        fields = ['name']
+        fields = ['id', 'name', 'feed_leg']
 
 
 
 
 class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
 class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ['name', 'form_factor', 'mgmt_only']
+        fields = ['id', 'name', 'type', 'mgmt_only']
 
 
 
 
 class FrontPortTemplateFilter(DeviceTypeComponentFilterSet):
 class FrontPortTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
-        fields = ['name', 'type']
+        fields = ['id', 'name', 'type']
 
 
 
 
 class RearPortTemplateFilter(DeviceTypeComponentFilterSet):
 class RearPortTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
-        fields = ['name', 'type']
+        fields = ['id', 'name', 'type', 'positions']
 
 
 
 
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
 class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
-        fields = ['name']
+        fields = ['id', 'name']
 
 
 
 
 class DeviceRoleFilter(NameSlugSearchFilterSet):
 class DeviceRoleFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceRole
         model = DeviceRole
-        fields = ['name', 'slug', 'color', 'vm_role']
+        fields = ['id', 'name', 'slug', 'color', 'vm_role']
 
 
 
 
 class PlatformFilter(NameSlugSearchFilterSet):
 class PlatformFilter(NameSlugSearchFilterSet):
@@ -417,7 +417,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = Platform
         model = Platform
-        fields = ['name', 'slug']
+        fields = ['id', 'name', 'slug', 'napalm_driver']
 
 
 
 
 class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
 class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
@@ -465,8 +465,6 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Platform (slug)',
         label='Platform (slug)',
     )
     )
-    name = NullableCharFieldFilter()
-    asset_tag = NullableCharFieldFilter()
     region_id = TreeNodeMultipleChoiceFilter(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         field_name='site__region__in',
         field_name='site__region__in',
@@ -498,10 +496,6 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         label='Rack (ID)',
         label='Rack (ID)',
     )
     )
-    position = django_filters.ChoiceFilter(
-        choices=DEVICE_POSITION_CHOICES,
-        null_label='Non-racked'
-    )
     cluster_id = django_filters.ModelMultipleChoiceFilter(
     cluster_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         label='VM cluster (ID)',
         label='VM cluster (ID)',
@@ -561,7 +555,7 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet):
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
-        fields = ['serial', 'face']
+        fields = ['id', 'name', 'serial', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -638,7 +632,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
-            Q(name__icontains=value)
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
         )
         )
 
 
 
 
@@ -651,7 +646,7 @@ class ConsolePortFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
-        fields = ['name', 'connection_status']
+        fields = ['id', 'name', 'description', 'connection_status']
 
 
 
 
 class ConsoleServerPortFilter(DeviceComponentFilterSet):
 class ConsoleServerPortFilter(DeviceComponentFilterSet):
@@ -663,7 +658,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
-        fields = ['name', 'connection_status']
+        fields = ['id', 'name', 'description', 'connection_status']
 
 
 
 
 class PowerPortFilter(DeviceComponentFilterSet):
 class PowerPortFilter(DeviceComponentFilterSet):
@@ -675,7 +670,7 @@ class PowerPortFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
-        fields = ['name', 'connection_status']
+        fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status']
 
 
 
 
 class PowerOutletFilter(DeviceComponentFilterSet):
 class PowerOutletFilter(DeviceComponentFilterSet):
@@ -687,7 +682,7 @@ class PowerOutletFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
-        fields = ['name', 'connection_status']
+        fields = ['id', 'name', 'feed_leg', 'description', 'connection_status']
 
 
 
 
 class InterfaceFilter(django_filters.FilterSet):
 class InterfaceFilter(django_filters.FilterSet):
@@ -713,9 +708,9 @@ class InterfaceFilter(django_filters.FilterSet):
         lookup_expr='isnull',
         lookup_expr='isnull',
         exclude=True
         exclude=True
     )
     )
-    type = django_filters.CharFilter(
-        method='filter_type',
-        label='Interface type',
+    kind = django_filters.CharFilter(
+        method='filter_kind',
+        label='Kind of interface',
     )
     )
     lag_id = django_filters.ModelMultipleChoiceFilter(
     lag_id = django_filters.ModelMultipleChoiceFilter(
         field_name='lag',
         field_name='lag',
@@ -735,20 +730,21 @@ class InterfaceFilter(django_filters.FilterSet):
         method='filter_vlan',
         method='filter_vlan',
         label='Assigned VID'
         label='Assigned VID'
     )
     )
-    form_factor = django_filters.MultipleChoiceFilter(
-        choices=IFACE_FF_CHOICES,
+    type = django_filters.MultipleChoiceFilter(
+        choices=IFACE_TYPE_CHOICES,
         null_value=None
         null_value=None
     )
     )
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only']
+        fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
-            Q(name__icontains=value)
+            Q(name__icontains=value) |
+            Q(description__icontains=value)
         ).distinct()
         ).distinct()
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
@@ -777,13 +773,12 @@ class InterfaceFilter(django_filters.FilterSet):
             Q(tagged_vlans__vid=value)
             Q(tagged_vlans__vid=value)
         )
         )
 
 
-    def filter_type(self, queryset, name, value):
+    def filter_kind(self, queryset, name, value):
         value = value.strip().lower()
         value = value.strip().lower()
         return {
         return {
-            'physical': queryset.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES),
-            'virtual': queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES),
-            'wireless': queryset.filter(form_factor__in=WIRELESS_IFACE_TYPES),
-            'lag': queryset.filter(form_factor=IFACE_FF_LAG),
+            'physical': queryset.exclude(type__in=NONCONNECTABLE_IFACE_TYPES),
+            'virtual': queryset.filter(type__in=VIRTUAL_IFACE_TYPES),
+            'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES),
         }.get(value, queryset.none())
         }.get(value, queryset.none())
 
 
     def _mac_address(self, queryset, name, value):
     def _mac_address(self, queryset, name, value):
@@ -806,7 +801,7 @@ class FrontPortFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = FrontPort
         model = FrontPort
-        fields = ['name', 'type']
+        fields = ['id', 'name', 'type', 'description']
 
 
 
 
 class RearPortFilter(DeviceComponentFilterSet):
 class RearPortFilter(DeviceComponentFilterSet):
@@ -818,14 +813,14 @@ class RearPortFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = RearPort
         model = RearPort
-        fields = ['name', 'type']
+        fields = ['id', 'name', 'type', 'positions', 'description']
 
 
 
 
 class DeviceBayFilter(DeviceComponentFilterSet):
 class DeviceBayFilter(DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
-        fields = ['name']
+        fields = ['id', 'name', 'description']
 
 
 
 
 class InventoryItemFilter(DeviceComponentFilterSet):
 class InventoryItemFilter(DeviceComponentFilterSet):
@@ -856,11 +851,10 @@ class InventoryItemFilter(DeviceComponentFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Manufacturer (slug)',
         label='Manufacturer (slug)',
     )
     )
-    asset_tag = NullableCharFieldFilter()
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
-        fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered']
+        fields = ['id', 'name', 'part_id', 'serial', 'asset_tag', 'discovered']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -906,7 +900,7 @@ class VirtualChassisFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = VirtualChassis
         model = VirtualChassis
-        fields = ['domain']
+        fields = ['id', 'domain']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -926,6 +920,9 @@ class CableFilter(django_filters.FilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=CABLE_TYPE_CHOICES
         choices=CABLE_TYPE_CHOICES
     )
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=CONNECTION_STATUS_CHOICES
+    )
     color = django_filters.MultipleChoiceFilter(
     color = django_filters.MultipleChoiceFilter(
         choices=COLOR_CHOICES
         choices=COLOR_CHOICES
     )
     )
@@ -940,7 +937,7 @@ class CableFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Cable
         model = Cable
-        fields = ['type', 'status', 'color', 'length', 'length_unit']
+        fields = ['id', 'label', 'length', 'length_unit']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
@@ -1003,14 +1000,14 @@ class PowerConnectionFilter(django_filters.FilterSet):
     def filter_site(self, queryset, name, value):
     def filter_site(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
-        return queryset.filter(connected_endpoint__device__site__slug=value)
+        return queryset.filter(_connected_poweroutlet__device__site__slug=value)
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(device__name__icontains=value) |
             Q(device__name__icontains=value) |
-            Q(connected_endpoint__device__name__icontains=value)
+            Q(_connected_poweroutlet__device__name__icontains=value)
         )
         )
 
 
 
 
@@ -1043,3 +1040,86 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
             Q(device__name__icontains=value) |
             Q(device__name__icontains=value) |
             Q(_connected_interface__device__name__icontains=value)
             Q(_connected_interface__device__name__icontains=value)
         )
         )
+
+
+class PowerPanelFilter(django_filters.FilterSet):
+    id__in = NumericInFilter(
+        field_name='id',
+        lookup_expr='in'
+    )
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site name (slug)',
+    )
+    rack_group_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='rack_group',
+        queryset=RackGroup.objects.all(),
+        label='Rack group (ID)',
+    )
+
+    class Meta:
+        model = PowerPanel
+        fields = ['name']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+
+class PowerFeedFilter(CustomFieldFilterSet):
+    id__in = NumericInFilter(
+        field_name='id',
+        lookup_expr='in'
+    )
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='power_panel__site',
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='power_panel__site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site name (slug)',
+    )
+    power_panel_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=PowerPanel.objects.all(),
+        label='Power panel (ID)',
+    )
+    rack_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='rack',
+        queryset=Rack.objects.all(),
+        label='Rack (ID)',
+    )
+    tag = TagFilter()
+
+    class Meta:
+        model = PowerFeed
+        fields = ['name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(comments__icontains=value)
+        )
+        return queryset.filter(qs_filter)

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


+ 665 - 49
netbox/dcim/forms.py

@@ -10,6 +10,7 @@ from mptt.forms import TreeNodeChoiceField
 from taggit.forms import TagField
 from taggit.forms import TagField
 from timezone_field import TimeZoneFormField
 from timezone_field import TimeZoneFormField
 
 
+from circuits.models import Circuit, Provider
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from ipam.models import IPAddress, VLAN, VLANGroup
 from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
@@ -17,18 +18,17 @@ from tenancy.forms import TenancyFilterForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
     APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
-    BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField,
-    ComponentForm, ConfirmationForm, ContentTypeSelect, CSVChoiceField, ExpandableNameField,
-    FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField,
-    StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
+    BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm,
+    ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField,
+    SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
 )
 )
 from virtualization.models import Cluster, ClusterGroup
 from virtualization.models import Cluster, ClusterGroup
 from .constants import *
 from .constants import *
 from .models import (
 from .models import (
     Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
     Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer,
     Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer,
-    InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
-    RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis
+    InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate,
+    Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
 )
 )
 
 
 DEVICE_BY_PK_RE = r'{\d+\}'
 DEVICE_BY_PK_RE = r'{\d+\}'
@@ -937,7 +937,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = [
         fields = [
-            'device_type', 'name',
+            'device_type', 'name', 'maximum_draw', 'allocated_draw',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
@@ -951,16 +951,29 @@ class PowerPortTemplateCreateForm(ComponentForm):
 
 
 
 
 class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
 class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPortTemplate.objects.all(),
+        required=False
+    )
 
 
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = [
         fields = [
-            'device_type', 'name',
+            'device_type', 'name', 'power_port', 'feed_leg',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
         }
         }
 
 
+    def __init__(self, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port choices to current DeviceType
+        self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
+            device_type=self.parent
+        )
+
 
 
 class PowerOutletTemplateCreateForm(ComponentForm):
 class PowerOutletTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
@@ -973,11 +986,11 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'form_factor', 'mgmt_only',
+            'device_type', 'name', 'type', 'mgmt_only',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
-            'form_factor': StaticSelect2(),
+            'type': StaticSelect2(),
         }
         }
 
 
 
 
@@ -985,8 +998,8 @@ class InterfaceTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
-    form_factor = forms.ChoiceField(
-        choices=IFACE_FF_CHOICES,
+    type = forms.ChoiceField(
+        choices=IFACE_TYPE_CHOICES,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
     mgmt_only = forms.BooleanField(
     mgmt_only = forms.BooleanField(
@@ -1000,8 +1013,8 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
         queryset=InterfaceTemplate.objects.all(),
         queryset=InterfaceTemplate.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    form_factor = forms.ChoiceField(
-        choices=add_blank_choice(IFACE_FF_CHOICES),
+    type = forms.ChoiceField(
+        choices=add_blank_choice(IFACE_TYPE_CHOICES),
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
@@ -1785,8 +1798,8 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
 
 
 
 
 class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
 class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
-    form_factor = forms.ChoiceField(
-        choices=IFACE_FF_CHOICES,
+    type = forms.ChoiceField(
+        choices=IFACE_TYPE_CHOICES,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
     enabled = forms.BooleanField(
     enabled = forms.BooleanField(
@@ -1821,7 +1834,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ConsolePort
         model = ConsolePort
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -1832,6 +1845,10 @@ class ConsolePortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -1849,7 +1866,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -1860,11 +1877,31 @@ class ConsoleServerPortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
 
 
 
 
+class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ConsoleServerPort.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description',
+        ]
+
+
 class ConsoleServerPortBulkRenameForm(BulkRenameForm):
 class ConsoleServerPortBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=ConsoleServerPort.objects.all(),
         queryset=ConsoleServerPort.objects.all(),
@@ -1891,7 +1928,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerPort
         model = PowerPort
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -1902,6 +1939,20 @@ class PowerPortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
+    maximum_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Maximum draw in watts"
+    )
+    allocated_draw = forms.IntegerField(
+        min_value=1,
+        required=False,
+        help_text="Allocated draw in watts"
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -1912,6 +1963,10 @@ class PowerPortCreateForm(ComponentForm):
 #
 #
 
 
 class PowerOutletForm(BootstrapMixin, forms.ModelForm):
 class PowerOutletForm(BootstrapMixin, forms.ModelForm):
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
@@ -1919,21 +1974,69 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerOutlet
         model = PowerOutlet
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'power_port', 'feed_leg', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
         }
         }
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port choices to the local device
+        if hasattr(self.instance, 'device'):
+            self.fields['power_port'].queryset = PowerPort.objects.filter(
+                device=self.instance.device
+            )
+
 
 
 class PowerOutletCreateForm(ComponentForm):
 class PowerOutletCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
+    power_port = forms.ModelChoiceField(
+        queryset=PowerPort.objects.all(),
+        required=False
+    )
+    feed_leg = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_LEG_CHOICES),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
     tags = TagField(
     tags = TagField(
         required=False
         required=False
     )
     )
 
 
+    def __init__(self, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        # Limit power_port choices to those on the parent device
+        self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent)
+
+
+class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerOutlet.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    feed_leg = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_LEG_CHOICES),
+        required=False,
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'feed_leg', 'description',
+        ]
+
 
 
 class PowerOutletBulkRenameForm(BulkRenameForm):
 class PowerOutletBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
@@ -1961,12 +2064,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
+            'device', 'name', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
             'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
             'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
-            'form_factor': StaticSelect2(),
+            'type': StaticSelect2(),
             'lag': StaticSelect2(),
             'lag': StaticSelect2(),
             'mode': StaticSelect2(),
             'mode': StaticSelect2(),
         }
         }
@@ -1984,12 +2087,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
         if self.is_bound:
         if self.is_bound:
             device = Device.objects.get(pk=self.data['device'])
             device = Device.objects.get(pk=self.data['device'])
             self.fields['lag'].queryset = Interface.objects.filter(
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
+                device__in=[device, device.get_vc_master()], type=IFACE_TYPE_LAG
             )
             )
         else:
         else:
             device = self.instance.device
             device = self.instance.device
             self.fields['lag'].queryset = Interface.objects.filter(
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
+                device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG
             )
             )
 
 
     def clean(self):
     def clean(self):
@@ -2101,8 +2204,8 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
-    form_factor = forms.ChoiceField(
-        choices=IFACE_FF_CHOICES,
+    type = forms.ChoiceField(
+        choices=IFACE_TYPE_CHOICES,
         widget=StaticSelect2(),
         widget=StaticSelect2(),
     )
     )
     enabled = forms.BooleanField(
     enabled = forms.BooleanField(
@@ -2153,7 +2256,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
         # Limit LAG choices to interfaces belonging to this device (or its VC master)
         # Limit LAG choices to interfaces belonging to this device (or its VC master)
         if self.parent is not None:
         if self.parent is not None:
             self.fields['lag'].queryset = Interface.objects.filter(
             self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG
+                device__in=[self.parent, self.parent.get_vc_master()], type=IFACE_TYPE_LAG
             )
             )
         else:
         else:
             self.fields['lag'].queryset = Interface.objects.none()
             self.fields['lag'].queryset = Interface.objects.none()
@@ -2164,8 +2267,8 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
-    form_factor = forms.ChoiceField(
-        choices=add_blank_choice(IFACE_FF_CHOICES),
+    type = forms.ChoiceField(
+        choices=add_blank_choice(IFACE_TYPE_CHOICES),
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
@@ -2217,7 +2320,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         if device is not None:
         if device is not None:
             self.fields['lag'].queryset = Interface.objects.filter(
             self.fields['lag'].queryset = Interface.objects.filter(
                 device__in=[device, device.get_vc_master()],
                 device__in=[device, device.get_vc_master()],
-                form_factor=IFACE_FF_LAG
+                type=IFACE_TYPE_LAG
             )
             )
         else:
         else:
             self.fields['lag'].choices = []
             self.fields['lag'].choices = []
@@ -2440,7 +2543,10 @@ class RearPortBulkDisconnectForm(ConfirmationForm):
 # Cables
 # Cables
 #
 #
 
 
-class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
+class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
+    """
+    Base form for connecting a Cable to a Device component
+    """
     termination_b_site = forms.ModelChoiceField(
     termination_b_site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site',
         label='Site',
@@ -2486,39 +2592,196 @@ class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
             }
             }
         )
         )
     )
     )
-    termination_b_type = forms.ModelChoiceField(
-        queryset=ContentType.objects.all(),
-        label='Type',
-        widget=ContentTypeSelect()
+
+    class Meta:
+        model = Cable
+        fields = [
+            'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status',
+            'label', 'color', 'length', 'length_unit',
+        ]
+
+
+class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
+    termination_b_id = forms.IntegerField(
+        label='Name',
+        widget=APISelect(
+            api_url='/api/dcim/console-ports/',
+            disabled_indicator='cable',
+        )
     )
     )
+
+
+class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
+    termination_b_id = forms.IntegerField(
+        label='Name',
+        widget=APISelect(
+            api_url='/api/dcim/console-server-ports/',
+            disabled_indicator='cable',
+        )
+    )
+
+
+class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
     termination_b_id = forms.IntegerField(
     termination_b_id = forms.IntegerField(
         label='Name',
         label='Name',
         widget=APISelect(
         widget=APISelect(
-            api_url='/api/dcim/{{termination_b_type}}s/',
+            api_url='/api/dcim/power-ports/',
             disabled_indicator='cable',
             disabled_indicator='cable',
-            conditional_query_params={
-                'termination_b_type__interface': 'type=physical',
+        )
+    )
+
+
+class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
+    termination_b_id = forms.IntegerField(
+        label='Name',
+        widget=APISelect(
+            api_url='/api/dcim/power-outlets/',
+            disabled_indicator='cable',
+        )
+    )
+
+
+class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
+    termination_b_id = forms.IntegerField(
+        label='Name',
+        widget=APISelect(
+            api_url='/api/dcim/interfaces/',
+            disabled_indicator='cable',
+            additional_query_params={
+                'kind': 'physical',
             }
             }
         )
         )
     )
     )
 
 
+
+class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
+    termination_b_id = forms.IntegerField(
+        label='Name',
+        widget=APISelect(
+            api_url='/api/dcim/front-ports/',
+            disabled_indicator='cable',
+        )
+    )
+
+
+class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
+    termination_b_id = forms.IntegerField(
+        label='Name',
+        widget=APISelect(
+            api_url='/api/dcim/rear-ports/',
+            disabled_indicator='cable',
+        )
+    )
+
+
+class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
+    termination_b_provider = forms.ModelChoiceField(
+        queryset=Provider.objects.all(),
+        label='Provider',
+        widget=APISelect(
+            api_url='/api/circuits/providers/',
+            filter_for={
+                'termination_b_circuit': 'provider_id',
+            }
+        )
+    )
+    termination_b_site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        label='Site',
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/sites/',
+            filter_for={
+                'termination_b_circuit': 'site_id',
+            }
+        )
+    )
+    termination_b_circuit = ChainedModelChoiceField(
+        queryset=Circuit.objects.all(),
+        chains=(
+            ('provider', 'termination_b_provider'),
+        ),
+        label='Circuit',
+        widget=APISelect(
+            api_url='/api/circuits/circuits/',
+            display_field='cid',
+            filter_for={
+                'termination_b_id': 'circuit_id',
+            }
+        )
+    )
+    termination_b_id = forms.IntegerField(
+        label='Side',
+        widget=APISelect(
+            api_url='/api/circuits/circuit-terminations/',
+            disabled_indicator='cable',
+            display_field='term_side'
+        )
+    )
+
     class Meta:
     class Meta:
         model = Cable
         model = Cable
         fields = [
         fields = [
-            'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_type',
-            'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit',
+            'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type',
+            'status', 'label', 'color', 'length', 'length_unit',
         ]
         ]
 
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
 
 
-        # Define available types for endpoint B based on the type of endpoint A
-        termination_a_type = self.instance.termination_a._meta.model_name
-        self.fields['termination_b_type'].queryset = ContentType.objects.filter(
-            model__in=COMPATIBLE_TERMINATION_TYPES.get(termination_a_type)
-        ).exclude(
-            model='circuittermination'
+class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
+    termination_b_site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        label='Site',
+        widget=APISelect(
+            api_url='/api/dcim/sites/',
+            display_field='cid',
+            filter_for={
+                'termination_b_rackgroup': 'site_id',
+                'termination_b_powerpanel': 'site_id',
+            }
+        )
+    )
+    termination_b_rackgroup = ChainedModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        label='Rack Group',
+        chains=(
+            ('site', 'termination_b_site'),
+        ),
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/rack-groups/',
+            display_field='cid',
+            filter_for={
+                'termination_b_powerpanel': 'rackgroup_id',
+            }
         )
         )
+    )
+    termination_b_powerpanel = ChainedModelChoiceField(
+        queryset=PowerPanel.objects.all(),
+        chains=(
+            ('site', 'termination_b_site'),
+            ('rack_group', 'termination_b_rackgroup'),
+        ),
+        label='Power Panel',
+        widget=APISelect(
+            api_url='/api/dcim/power-panels/',
+            filter_for={
+                'termination_b_id': 'power_panel_id',
+            }
+        )
+    )
+    termination_b_id = forms.IntegerField(
+        label='Name',
+        widget=APISelect(
+            api_url='/api/dcim/power-feeds/',
+        )
+    )
+
+    class Meta:
+        model = Cable
+        fields = [
+            'termination_b_rackgroup', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
+            'color', 'length', 'length_unit',
+        ]
 
 
 
 
 class CableForm(BootstrapMixin, forms.ModelForm):
 class CableForm(BootstrapMixin, forms.ModelForm):
@@ -2752,7 +3015,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = DeviceBay
         model = DeviceBay
         fields = [
         fields = [
-            'device', 'name', 'tags',
+            'device', 'name', 'description', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
@@ -3101,3 +3364,356 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
             null_option=True,
             null_option=True,
         )
         )
     )
     )
+
+
+#
+# Power panels
+#
+
+class PowerPanelForm(BootstrapMixin, forms.ModelForm):
+    rack_group = ChainedModelChoiceField(
+        queryset=RackGroup.objects.all(),
+        chains=(
+            ('site', 'site'),
+        ),
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/rack-groups/',
+        )
+    )
+
+    class Meta:
+        model = PowerPanel
+        fields = [
+            'site', 'rack_group', 'name',
+        ]
+        widgets = {
+            'site': APISelect(
+                api_url="/api/dcim/sites/",
+                filter_for={
+                    'rack_group': 'site_id',
+                }
+            ),
+        }
+
+
+class PowerPanelCSVForm(forms.ModelForm):
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text='Name of parent site',
+        error_messages={
+            'invalid_choice': 'Site not found.',
+        }
+    )
+    rack_group_name = forms.CharField(
+        required=False,
+        help_text="Rack group name (optional)"
+    )
+
+    class Meta:
+        model = PowerPanel
+        fields = PowerPanel.csv_headers
+
+    def clean(self):
+
+        super().clean()
+
+        site = self.cleaned_data.get('site')
+        rack_group_name = self.cleaned_data.get('rack_group_name')
+
+        # Validate rack group
+        if rack_group_name:
+            try:
+                self.instance.rack_group = RackGroup.objects.get(site=site, name=rack_group_name)
+            except RackGroup.DoesNotExist:
+                raise forms.ValidationError(
+                    "Rack group {} not found in site {}".format(rack_group_name, site)
+                )
+
+
+class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = PowerPanel
+    q = forms.CharField(
+        required=False,
+        label='Search'
+    )
+    site = FilterChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+            filter_for={
+                'rack_group_id': 'site',
+            }
+        )
+    )
+    rack_group_id = FilterChoiceField(
+        queryset=RackGroup.objects.all(),
+        label='Rack group (ID)',
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/rack-groups/",
+            null_option=True,
+        )
+    )
+
+
+#
+# Power feeds
+#
+
+class PowerFeedForm(BootstrapMixin, CustomFieldForm):
+    site = ChainedModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/sites/',
+            filter_for={
+                'power_panel': 'site_id',
+                'rack': 'site_id',
+            }
+        )
+    )
+    comments = CommentField()
+    tags = TagField(
+        required=False
+    )
+
+    class Meta:
+        model = PowerFeed
+        fields = [
+            'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
+            'max_utilization', 'comments', 'tags',
+        ]
+        widgets = {
+            'power_panel': APISelect(
+                api_url="/api/dcim/power-panels/"
+            ),
+            'rack': APISelect(
+                api_url="/api/dcim/racks/"
+            ),
+            'status': StaticSelect2(),
+            'type': StaticSelect2(),
+            'supply': StaticSelect2(),
+            'phase': StaticSelect2(),
+        }
+
+    def __init__(self, *args, **kwargs):
+
+        super().__init__(*args, **kwargs)
+
+        # Initialize site field
+        if self.instance and hasattr(self.instance, 'power_panel'):
+            self.initial['site'] = self.instance.power_panel.site
+
+
+class PowerFeedCSVForm(forms.ModelForm):
+    site = forms.ModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        help_text='Name of parent site',
+        error_messages={
+            'invalid_choice': 'Site not found.',
+        }
+    )
+    panel_name = forms.ModelChoiceField(
+        queryset=PowerPanel.objects.all(),
+        to_field_name='name',
+        help_text='Name of upstream power panel',
+        error_messages={
+            'invalid_choice': 'Power panel not found.',
+        }
+    )
+    rack_group = forms.CharField(
+        required=False,
+        help_text="Rack group name (optional)"
+    )
+    rack_name = forms.CharField(
+        required=False,
+        help_text="Rack name (optional)"
+    )
+    status = CSVChoiceField(
+        choices=POWERFEED_STATUS_CHOICES,
+        required=False,
+        help_text='Operational status'
+    )
+    type = CSVChoiceField(
+        choices=POWERFEED_TYPE_CHOICES,
+        required=False,
+        help_text='Primary or redundant'
+    )
+    supply = CSVChoiceField(
+        choices=POWERFEED_SUPPLY_CHOICES,
+        required=False,
+        help_text='AC/DC'
+    )
+    phase = CSVChoiceField(
+        choices=POWERFEED_PHASE_CHOICES,
+        required=False,
+        help_text='Single or three-phase'
+    )
+
+    class Meta:
+        model = PowerFeed
+        fields = PowerFeed.csv_headers
+
+    def clean(self):
+
+        super().clean()
+
+        site = self.cleaned_data.get('site')
+        panel_name = self.cleaned_data.get('panel_name')
+        rack_group = self.cleaned_data.get('rack_group')
+        rack_name = self.cleaned_data.get('rack_name')
+
+        # Validate power panel
+        if panel_name:
+            try:
+                self.instance.power_panel = PowerPanel.objects.get(site=site, name=panel_name)
+            except Rack.DoesNotExist:
+                raise forms.ValidationError(
+                    "Power panel {} not found in site {}".format(panel_name, site)
+                )
+
+        # Validate rack
+        if rack_name:
+            try:
+                self.instance.rack = Rack.objects.get(site=site, rack_group=rack_group, name=rack_name)
+            except Rack.DoesNotExist:
+                raise forms.ValidationError(
+                    "Rack {} not found in site {}, group {}".format(rack_name, site, rack_group)
+                )
+
+
+class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=PowerFeed.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    powerpanel = forms.ModelChoiceField(
+        queryset=PowerPanel.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/sites",
+            filter_for={
+                'rackgroup': 'site_id',
+            }
+        )
+    )
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        widget=APISelect(
+            api_url="/api/dcim/racks",
+        )
+    )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_STATUS_CHOICES),
+        required=False,
+        initial='',
+        widget=StaticSelect2()
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_TYPE_CHOICES),
+        required=False,
+        initial='',
+        widget=StaticSelect2()
+    )
+    supply = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_SUPPLY_CHOICES),
+        required=False,
+        initial='',
+        widget=StaticSelect2()
+    )
+    phase = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_PHASE_CHOICES),
+        required=False,
+        initial='',
+        widget=StaticSelect2()
+    )
+    voltage = forms.IntegerField(
+        required=False
+    )
+    amperage = forms.IntegerField(
+        required=False
+    )
+    max_utilization = forms.IntegerField(
+        required=False
+    )
+    comments = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = [
+            'rackgroup', 'comments',
+        ]
+
+
+class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = PowerFeed
+    q = forms.CharField(
+        required=False,
+        label='Search'
+    )
+    site = FilterChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/sites/",
+            value_field="slug",
+            filter_for={
+                'power_panel_id': 'site',
+                'rack_id': 'site',
+            }
+        )
+    )
+    power_panel_id = FilterChoiceField(
+        queryset=PowerPanel.objects.all(),
+        label='Power panel',
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/power-panels/",
+            null_option=True,
+        )
+    )
+    rack_id = FilterChoiceField(
+        queryset=Rack.objects.all(),
+        label='Rack',
+        null_label='-- None --',
+        widget=APISelectMultiple(
+            api_url="/api/dcim/racks/",
+            null_option=True,
+        )
+    )
+    status = forms.MultipleChoiceField(
+        choices=POWERFEED_STATUS_CHOICES,
+        required=False,
+        widget=StaticSelect2Multiple()
+    )
+    type = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_TYPE_CHOICES),
+        required=False,
+        widget=StaticSelect2()
+    )
+    supply = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_SUPPLY_CHOICES),
+        required=False,
+        widget=StaticSelect2()
+    )
+    phase = forms.ChoiceField(
+        choices=add_blank_choice(POWERFEED_PHASE_CHOICES),
+        required=False,
+        widget=StaticSelect2()
+    )
+    voltage = forms.IntegerField(
+        required=False
+    )
+    amperage = forms.IntegerField(
+        required=False
+    )
+    max_utilization = forms.IntegerField(
+        required=False
+    )

+ 1 - 1
netbox/dcim/managers.py

@@ -21,7 +21,7 @@ class InterfaceQuerySet(QuerySet):
         Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
         Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
         wireless).
         wireless).
         """
         """
-        return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)
+        return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES)
 
 
 
 
 class InterfaceManager(Manager):
 class InterfaceManager(Manager):

+ 2 - 2
netbox/dcim/migrations/0066_cables.py

@@ -174,8 +174,8 @@ class Migration(migrations.Migration):
                 ('length', models.PositiveSmallIntegerField(blank=True, null=True)),
                 ('length', models.PositiveSmallIntegerField(blank=True, null=True)),
                 ('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)),
                 ('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)),
                 ('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
                 ('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
-                ('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
-                ('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
+                ('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
+                ('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
             ],
             ],
         ),
         ),
         migrations.AlterUniqueTogether(
         migrations.AlterUniqueTogether(

+ 85 - 0
netbox/dcim/migrations/0070_custom_tag_models.py

@@ -0,0 +1,85 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0069_deprecate_nullablecharfield'),
+        ('extras', '0019_tag_taggeditem'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='consoleport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='consoleserverport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='devicebay',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='devicetype',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='frontport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='inventoryitem',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='poweroutlet',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='powerport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='rack',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='rearport',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='site',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='virtualchassis',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 38 - 0
netbox/dcim/migrations/0071_device_components_add_description.py

@@ -0,0 +1,38 @@
+# Generated by Django 2.1.7 on 2019-02-20 18:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0070_custom_tag_models'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='consoleport',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='consoleserverport',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='devicebay',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='description',
+            field=models.CharField(blank=True, max_length=100),
+        ),
+    ]

+ 134 - 0
netbox/dcim/migrations/0072_powerfeeds.py

@@ -0,0 +1,134 @@
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0021_add_color_comments_changelog_to_tag'),
+        ('dcim', '0071_device_components_add_description'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PowerFeed',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('name', models.CharField(max_length=50)),
+                ('status', models.PositiveSmallIntegerField(default=1)),
+                ('type', models.PositiveSmallIntegerField(default=1)),
+                ('supply', models.PositiveSmallIntegerField(default=1)),
+                ('phase', models.PositiveSmallIntegerField(default=1)),
+                ('voltage', models.PositiveSmallIntegerField(default=120, validators=[django.core.validators.MinValueValidator(1)])),
+                ('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
+                ('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
+                ('available_power', models.PositiveSmallIntegerField(default=0, editable=False)),
+                ('comments', models.TextField(blank=True)),
+                ('cable', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable')),
+            ],
+            options={
+                'ordering': ['power_panel', 'name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='PowerPanel',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('name', models.CharField(max_length=50)),
+                ('rack_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.RackGroup')),
+                ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dcim.Site')),
+            ],
+            options={
+                'ordering': ['site', 'name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='power_panel',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.PowerPanel'),
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='rack',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.Rack'),
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='connected_endpoint',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerPort'),
+        ),
+        migrations.AddField(
+            model_name='powerfeed',
+            name='connection_status',
+            field=models.NullBooleanField(),
+        ),
+        migrations.RenameField(
+            model_name='powerport',
+            old_name='connected_endpoint',
+            new_name='_connected_poweroutlet',
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='_connected_powerfeed',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerFeed'),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='allocated_draw',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
+        ),
+        migrations.AddField(
+            model_name='powerport',
+            name='maximum_draw',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
+        ),
+        migrations.AddField(
+            model_name='powerporttemplate',
+            name='allocated_draw',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
+        ),
+        migrations.AddField(
+            model_name='powerporttemplate',
+            name='maximum_draw',
+            field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
+        ),
+        migrations.AlterUniqueTogether(
+            name='powerpanel',
+            unique_together={('site', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='powerfeed',
+            unique_together={('power_panel', 'name')},
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='feed_leg',
+            field=models.PositiveSmallIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='poweroutlet',
+            name='power_port',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlets', to='dcim.PowerPort'),
+        ),
+        migrations.AddField(
+            model_name='poweroutlettemplate',
+            name='feed_leg',
+            field=models.PositiveSmallIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='poweroutlettemplate',
+            name='power_port',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlet_templates', to='dcim.PowerPortTemplate'),
+        ),
+    ]

+ 23 - 0
netbox/dcim/migrations/0073_interface_form_factor_to_type.py

@@ -0,0 +1,23 @@
+# Generated by Django 2.1.7 on 2019-04-12 17:27
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0072_powerfeeds'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='interface',
+            old_name='form_factor',
+            new_name='type',
+        ),
+        migrations.RenameField(
+            model_name='interfacetemplate',
+            old_name='form_factor',
+            new_name='type',
+        ),
+    ]

+ 435 - 88
netbox/dcim/models.py

@@ -9,13 +9,13 @@ from django.contrib.postgres.fields import ArrayField, JSONField
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
-from django.db.models import Count, Q
+from django.db.models import Case, Count, Q, Sum, When, F, Subquery, OuterRef
 from django.urls import reverse
 from django.urls import reverse
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
-from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
+from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
 from utilities.fields import ColorField
 from utilities.fields import ColorField
 from utilities.managers import NaturalOrderingManager
 from utilities.managers import NaturalOrderingManager
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
@@ -46,6 +46,10 @@ class ComponentTemplateModel(models.Model):
 
 
 
 
 class ComponentModel(models.Model):
 class ComponentModel(models.Model):
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
 
 
     class Meta:
     class Meta:
         abstract = True
         abstract = True
@@ -319,7 +323,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
         'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
@@ -359,32 +363,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
     def get_status_class(self):
     def get_status_class(self):
         return STATUS_CLASSES[self.status]
         return STATUS_CLASSES[self.status]
 
 
-    @property
-    def count_prefixes(self):
-        return self.prefixes.count()
-
-    @property
-    def count_vlans(self):
-        return self.vlans.count()
-
-    @property
-    def count_racks(self):
-        return Rack.objects.filter(site=self).count()
-
-    @property
-    def count_devices(self):
-        return Device.objects.filter(site=self).count()
-
-    @property
-    def count_circuits(self):
-        from circuits.models import Circuit
-        return Circuit.objects.filter(terminations__site=self).count()
-
-    @property
-    def count_vms(self):
-        from virtualization.models import VirtualMachine
-        return VirtualMachine.objects.filter(cluster__site=self).count()
-
 
 
 #
 #
 # Racks
 # Racks
@@ -566,7 +544,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
         'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
@@ -756,6 +734,25 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
         u_available = len(self.get_available_units())
         u_available = len(self.get_available_units())
         return int(float(self.u_height - u_available) / self.u_height * 100)
         return int(float(self.u_height - u_available) / self.u_height * 100)
 
 
+    def get_power_utilization(self):
+        """
+        Determine the utilization rate of power in the rack and return it as a percentage.
+        """
+        power_stats = PowerFeed.objects.filter(
+            rack=self
+        ).annotate(
+            allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
+        ).values(
+            'allocated_draw_total',
+            'available_power'
+        )
+
+        if power_stats:
+            allocated_draw_total = sum(x['allocated_draw_total'] for x in power_stats)
+            available_power_total = sum(x['available_power'] for x in power_stats)
+            return int(allocated_draw_total / available_power_total * 100) or 0
+        return 0
+
 
 
 class RackReservation(ChangeLoggedModel):
 class RackReservation(ChangeLoggedModel):
     """
     """
@@ -914,7 +911,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
         'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
@@ -1049,6 +1046,18 @@ class PowerPortTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    maximum_draw = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        help_text="Maximum current draw (watts)"
+    )
+    allocated_draw = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        help_text="Allocated current draw (watts)"
+    )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
 
 
@@ -1072,6 +1081,19 @@ class PowerOutletTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    power_port = models.ForeignKey(
+        to='dcim.PowerPortTemplate',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='poweroutlet_templates'
+    )
+    feed_leg = models.PositiveSmallIntegerField(
+        choices=POWERFEED_LEG_CHOICES,
+        blank=True,
+        null=True,
+        help_text="Phase (for three-phase feeds)"
+    )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
 
 
@@ -1082,6 +1104,14 @@ class PowerOutletTemplate(ComponentTemplateModel):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    def clean(self):
+
+        # Validate power port assignment
+        if self.power_port and self.power_port.device_type != self.device_type:
+            raise ValidationError(
+                "Parent power port ({}) must belong to the same device type".format(self.power_port)
+            )
+
 
 
 class InterfaceTemplate(ComponentTemplateModel):
 class InterfaceTemplate(ComponentTemplateModel):
     """
     """
@@ -1095,9 +1125,9 @@ class InterfaceTemplate(ComponentTemplateModel):
     name = models.CharField(
     name = models.CharField(
         max_length=64
         max_length=64
     )
     )
-    form_factor = models.PositiveSmallIntegerField(
-        choices=IFACE_FF_CHOICES,
-        default=IFACE_FF_10GE_SFP_PLUS
+    type = models.PositiveSmallIntegerField(
+        choices=IFACE_TYPE_CHOICES,
+        default=IFACE_TYPE_10GE_SFP_PLUS
     )
     )
     mgmt_only = models.BooleanField(
     mgmt_only = models.BooleanField(
         default=False,
         default=False,
@@ -1113,6 +1143,22 @@ class InterfaceTemplate(ComponentTemplateModel):
     def __str__(self):
     def __str__(self):
         return self.name
         return self.name
 
 
+    # TODO: Remove in v2.7
+    @property
+    def form_factor(self):
+        """
+        Backward-compatibility for form_factor
+        """
+        return self.type
+
+    # TODO: Remove in v2.7
+    @form_factor.setter
+    def form_factor(self, value):
+        """
+        Backward-compatibility for form_factor
+        """
+        self.type = value
+
 
 
 class FrontPortTemplate(ComponentTemplateModel):
 class FrontPortTemplate(ComponentTemplateModel):
     """
     """
@@ -1455,7 +1501,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
         'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
@@ -1610,7 +1656,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
                  self.device_type.poweroutlet_templates.all()]
                  self.device_type.poweroutlet_templates.all()]
             )
             )
             Interface.objects.bulk_create(
             Interface.objects.bulk_create(
-                [Interface(device=self, name=template.name, form_factor=template.form_factor,
+                [Interface(device=self, name=template.name, type=template.type,
                            mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
                            mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
             )
             )
             RearPort.objects.bulk_create([
             RearPort.objects.bulk_create([
@@ -1758,9 +1804,9 @@ class ConsolePort(CableTermination, ComponentModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name']
+    csv_headers = ['device', 'name', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']
@@ -1776,6 +1822,7 @@ class ConsolePort(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.description,
         )
         )
 
 
 
 
@@ -1801,9 +1848,9 @@ class ConsoleServerPort(CableTermination, ComponentModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name']
+    csv_headers = ['device', 'name', 'description']
 
 
     class Meta:
     class Meta:
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
@@ -1818,6 +1865,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.description,
         )
         )
 
 
 
 
@@ -1837,22 +1885,41 @@ class PowerPort(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
-    connected_endpoint = models.OneToOneField(
+    maximum_draw = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        help_text="Maximum current draw (watts)"
+    )
+    allocated_draw = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1)],
+        help_text="Allocated current draw (watts)"
+    )
+    _connected_poweroutlet = models.OneToOneField(
         to='dcim.PowerOutlet',
         to='dcim.PowerOutlet',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
         related_name='connected_endpoint',
         related_name='connected_endpoint',
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    _connected_powerfeed = models.OneToOneField(
+        to='dcim.PowerFeed',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
     connection_status = models.NullBooleanField(
     connection_status = models.NullBooleanField(
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
         blank=True
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name']
+    csv_headers = ['device', 'name', 'maximum_draw', 'allocated_draw', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']
@@ -1868,8 +1935,76 @@ class PowerPort(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.maximum_draw,
+            self.allocated_draw,
+            self.description,
         )
         )
 
 
+    @property
+    def connected_endpoint(self):
+        if self._connected_poweroutlet:
+            return self._connected_poweroutlet
+        return self._connected_powerfeed
+
+    @connected_endpoint.setter
+    def connected_endpoint(self, value):
+        if value is None:
+            self._connected_poweroutlet = None
+            self._connected_powerfeed = None
+        elif isinstance(value, PowerOutlet):
+            self._connected_poweroutlet = value
+            self._connected_powerfeed = None
+        elif isinstance(value, PowerFeed):
+            self._connected_poweroutlet = None
+            self._connected_powerfeed = value
+        else:
+            raise ValueError(
+                "Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value))
+            )
+
+    def get_power_draw(self):
+        """
+        Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort.
+        """
+        # Calculate aggregate draw of all child power outlets if no numbers have been defined manually
+        if self.allocated_draw is None and self.maximum_draw is None:
+            outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
+            utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
+                maximum_draw_total=Sum('maximum_draw'),
+                allocated_draw_total=Sum('allocated_draw'),
+            )
+            ret = {
+                'allocated': utilization['allocated_draw_total'] or 0,
+                'maximum': utilization['maximum_draw_total'] or 0,
+                'outlet_count': len(outlet_ids),
+                'legs': [],
+            }
+
+            # Calculate per-leg aggregates for three-phase feeds
+            if self._connected_powerfeed and self._connected_powerfeed.phase == POWERFEED_PHASE_3PHASE:
+                for leg, leg_name in POWERFEED_LEG_CHOICES:
+                    outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
+                    utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
+                        maximum_draw_total=Sum('maximum_draw'),
+                        allocated_draw_total=Sum('allocated_draw'),
+                    )
+                    ret['legs'].append({
+                        'name': leg_name,
+                        'allocated': utilization['allocated_draw_total'] or 0,
+                        'maximum': utilization['maximum_draw_total'] or 0,
+                        'outlet_count': len(outlet_ids),
+                    })
+
+            return ret
+
+        # Default to administratively defined values
+        return {
+            'allocated': self.allocated_draw or 0,
+            'maximum': self.maximum_draw or 0,
+            'outlet_count': PowerOutlet.objects.filter(power_port=self).count(),
+            'legs': [],
+        }
+
 
 
 #
 #
 # Power outlets
 # Power outlets
@@ -1887,15 +2022,28 @@ class PowerOutlet(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=50
         max_length=50
     )
     )
+    power_port = models.ForeignKey(
+        to='dcim.PowerPort',
+        on_delete=models.SET_NULL,
+        blank=True,
+        null=True,
+        related_name='poweroutlets'
+    )
+    feed_leg = models.PositiveSmallIntegerField(
+        choices=POWERFEED_LEG_CHOICES,
+        blank=True,
+        null=True,
+        help_text="Phase (for three-phase feeds)"
+    )
     connection_status = models.NullBooleanField(
     connection_status = models.NullBooleanField(
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         blank=True
         blank=True
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name']
+    csv_headers = ['device', 'name', 'power_port', 'feed_leg', 'description']
 
 
     class Meta:
     class Meta:
         unique_together = ['device', 'name']
         unique_together = ['device', 'name']
@@ -1910,8 +2058,19 @@ class PowerOutlet(CableTermination, ComponentModel):
         return (
         return (
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
+            self.power_port.name if self.power_port else None,
+            self.get_feed_leg_display(),
+            self.description,
         )
         )
 
 
+    def clean(self):
+
+        # Validate power port assignment
+        if self.power_port and self.power_port.device != self.device:
+            raise ValidationError(
+                "Parent power port ({}) must belong to the same device".format(self.power_port)
+            )
+
 
 
 #
 #
 # Interfaces
 # Interfaces
@@ -1965,9 +2124,9 @@ class Interface(CableTermination, ComponentModel):
         blank=True,
         blank=True,
         verbose_name='Parent LAG'
         verbose_name='Parent LAG'
     )
     )
-    form_factor = models.PositiveSmallIntegerField(
-        choices=IFACE_FF_CHOICES,
-        default=IFACE_FF_10GE_SFP_PLUS
+    type = models.PositiveSmallIntegerField(
+        choices=IFACE_TYPE_CHOICES,
+        default=IFACE_TYPE_10GE_SFP_PLUS
     )
     )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
         default=True
         default=True
@@ -1988,10 +2147,6 @@ class Interface(CableTermination, ComponentModel):
         verbose_name='OOB Management',
         verbose_name='OOB Management',
         help_text='This interface is used only for out-of-band management'
         help_text='This interface is used only for out-of-band management'
     )
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
     mode = models.PositiveSmallIntegerField(
     mode = models.PositiveSmallIntegerField(
         choices=IFACE_MODE_CHOICES,
         choices=IFACE_MODE_CHOICES,
         blank=True,
         blank=True,
@@ -2013,10 +2168,10 @@ class Interface(CableTermination, ComponentModel):
     )
     )
 
 
     objects = InterfaceManager()
     objects = InterfaceManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
-        'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
+        'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
         'description', 'mode',
         'description', 'mode',
     ]
     ]
 
 
@@ -2036,7 +2191,7 @@ class Interface(CableTermination, ComponentModel):
             self.virtual_machine.name if self.virtual_machine else None,
             self.virtual_machine.name if self.virtual_machine else None,
             self.name,
             self.name,
             self.lag.name if self.lag else None,
             self.lag.name if self.lag else None,
-            self.get_form_factor_display(),
+            self.get_type_display(),
             self.enabled,
             self.enabled,
             self.mac_address,
             self.mac_address,
             self.mtu,
             self.mtu,
@@ -2054,18 +2209,18 @@ class Interface(CableTermination, ComponentModel):
             raise ValidationError("An interface must belong to either a device or a virtual machine.")
             raise ValidationError("An interface must belong to either a device or a virtual machine.")
 
 
         # VM interfaces must be virtual
         # VM interfaces must be virtual
-        if self.virtual_machine and self.form_factor is not IFACE_FF_VIRTUAL:
+        if self.virtual_machine and self.type is not IFACE_TYPE_VIRTUAL:
             raise ValidationError({
             raise ValidationError({
-                'form_factor': "Virtual machines can only have virtual interfaces."
+                'type': "Virtual machines can only have virtual interfaces."
             })
             })
 
 
         # Virtual interfaces cannot be connected
         # Virtual interfaces cannot be connected
-        if self.form_factor in NONCONNECTABLE_IFACE_TYPES and (
+        if self.type in NONCONNECTABLE_IFACE_TYPES and (
                 self.cable or getattr(self, 'circuit_termination', False)
                 self.cable or getattr(self, 'circuit_termination', False)
         ):
         ):
             raise ValidationError({
             raise ValidationError({
-                'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
-                               "Disconnect the interface or choose a suitable form factor."
+                'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
+                        "Disconnect the interface or choose a suitable type."
             })
             })
 
 
         # An interface's LAG must belong to the same device (or VC master)
         # An interface's LAG must belong to the same device (or VC master)
@@ -2077,15 +2232,15 @@ class Interface(CableTermination, ComponentModel):
             })
             })
 
 
         # A virtual interface cannot have a parent LAG
         # A virtual interface cannot have a parent LAG
-        if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
+        if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
             raise ValidationError({
             raise ValidationError({
-                'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
+                'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display())
             })
             })
 
 
         # Only a LAG can have LAG members
         # Only a LAG can have LAG members
-        if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
+        if self.type != IFACE_TYPE_LAG and self.member_interfaces.exists():
             raise ValidationError({
             raise ValidationError({
-                'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format(
+                'type': "Cannot change interface type; it has LAG members ({}).".format(
                     ", ".join([iface.name for iface in self.member_interfaces.all()])
                     ", ".join([iface.name for iface in self.member_interfaces.all()])
                 )
                 )
             })
             })
@@ -2131,6 +2286,22 @@ class Interface(CableTermination, ComponentModel):
             object_data=serialize_object(self)
             object_data=serialize_object(self)
         ).save()
         ).save()
 
 
+    # TODO: Remove in v2.7
+    @property
+    def form_factor(self):
+        """
+        Backward-compatibility for form_factor
+        """
+        return self.type
+
+    # TODO: Remove in v2.7
+    @form_factor.setter
+    def form_factor(self, value):
+        """
+        Backward-compatibility for form_factor
+        """
+        self.type = value
+
     @property
     @property
     def connected_endpoint(self):
     def connected_endpoint(self):
         if self._connected_interface:
         if self._connected_interface:
@@ -2161,19 +2332,19 @@ class Interface(CableTermination, ComponentModel):
 
 
     @property
     @property
     def is_connectable(self):
     def is_connectable(self):
-        return self.form_factor not in NONCONNECTABLE_IFACE_TYPES
+        return self.type not in NONCONNECTABLE_IFACE_TYPES
 
 
     @property
     @property
     def is_virtual(self):
     def is_virtual(self):
-        return self.form_factor in VIRTUAL_IFACE_TYPES
+        return self.type in VIRTUAL_IFACE_TYPES
 
 
     @property
     @property
     def is_wireless(self):
     def is_wireless(self):
-        return self.form_factor in WIRELESS_IFACE_TYPES
+        return self.type in WIRELESS_IFACE_TYPES
 
 
     @property
     @property
     def is_lag(self):
     def is_lag(self):
-        return self.form_factor == IFACE_FF_LAG
+        return self.type == IFACE_TYPE_LAG
 
 
     @property
     @property
     def count_ipaddresses(self):
     def count_ipaddresses(self):
@@ -2208,13 +2379,9 @@ class FrontPort(CableTermination, ComponentModel):
         default=1,
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    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']
 
 
@@ -2274,13 +2441,9 @@ class RearPort(CableTermination, ComponentModel):
         default=1,
         default=1,
         validators=[MinValueValidator(1), MaxValueValidator(64)]
         validators=[MinValueValidator(1), MaxValueValidator(64)]
     )
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
 
 
@@ -2327,9 +2490,9 @@ class DeviceBay(ComponentModel):
     )
     )
 
 
     objects = NaturalOrderingManager()
     objects = NaturalOrderingManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
-    csv_headers = ['device', 'name', 'installed_device']
+    csv_headers = ['device', 'name', 'installed_device', 'description']
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']
@@ -2346,6 +2509,7 @@ class DeviceBay(ComponentModel):
             self.device.identifier,
             self.device.identifier,
             self.name,
             self.name,
             self.installed_device.identifier if self.installed_device else None,
             self.installed_device.identifier if self.installed_device else None,
+            self.description,
         )
         )
 
 
     def clean(self):
     def clean(self):
@@ -2415,12 +2579,8 @@ class InventoryItem(ComponentModel):
         default=False,
         default=False,
         verbose_name='Discovered'
         verbose_name='Discovered'
     )
     )
-    description = models.CharField(
-        max_length=100,
-        blank=True
-    )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
         'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
@@ -2467,7 +2627,7 @@ class VirtualChassis(ChangeLoggedModel):
         blank=True
         blank=True
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['master', 'domain']
     csv_headers = ['master', 'domain']
 
 
@@ -2628,11 +2788,11 @@ class Cable(ChangeLoggedModel):
             if (
             if (
                 (
                 (
                     isinstance(endpoint_a, Interface) and
                     isinstance(endpoint_a, Interface) and
-                    endpoint_a.form_factor == IFACE_FF_VIRTUAL
+                    endpoint_a.type == IFACE_TYPE_VIRTUAL
                 ) or
                 ) or
                 (
                 (
                     isinstance(endpoint_b, Interface) and
                     isinstance(endpoint_b, Interface) and
-                    endpoint_b.form_factor == IFACE_FF_VIRTUAL
+                    endpoint_b.type == IFACE_TYPE_VIRTUAL
                 )
                 )
             ):
             ):
                 raise ValidationError("Cannot connect to a virtual interface")
                 raise ValidationError("Cannot connect to a virtual interface")
@@ -2668,6 +2828,14 @@ class Cable(ChangeLoggedModel):
     def get_status_class(self):
     def get_status_class(self):
         return 'success' if self.status else 'info'
         return 'success' if self.status else 'info'
 
 
+    def get_compatible_types(self):
+        """
+        Return all termination types compatible with termination A.
+        """
+        if self.termination_a is None:
+            return
+        return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
+
     def get_path_endpoints(self):
     def get_path_endpoints(self):
         """
         """
         Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
         Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
@@ -2690,3 +2858,182 @@ class Cable(ChangeLoggedModel):
         b_endpoint = b_path[-1][2]
         b_endpoint = b_path[-1][2]
 
 
         return a_endpoint, b_endpoint, path_status
         return a_endpoint, b_endpoint, path_status
+
+
+#
+# Power
+#
+
+class PowerPanel(ChangeLoggedModel):
+    """
+    A distribution point for electrical power; e.g. a data center RPP.
+    """
+    site = models.ForeignKey(
+        to='Site',
+        on_delete=models.PROTECT
+    )
+    rack_group = models.ForeignKey(
+        to='RackGroup',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=50
+    )
+
+    csv_headers = ['site', 'rack_group_name', 'name']
+
+    class Meta:
+        ordering = ['site', 'name']
+        unique_together = ['site', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:powerpanel', args=[self.pk])
+
+    def to_csv(self):
+        return (
+            self.site.name,
+            self.rack_group.name if self.rack_group else None,
+            self.name,
+        )
+
+    def clean(self):
+
+        # RackGroup must belong to assigned Site
+        if self.rack_group and self.rack_group.site != self.site:
+            raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
+                self.rack_group, self.rack_group.site, self.site
+            ))
+
+
+class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
+    """
+    An electrical circuit delivered from a PowerPanel.
+    """
+    power_panel = models.ForeignKey(
+        to='PowerPanel',
+        on_delete=models.PROTECT,
+        related_name='powerfeeds'
+    )
+    rack = models.ForeignKey(
+        to='Rack',
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True
+    )
+    connected_endpoint = models.OneToOneField(
+        to='dcim.PowerPort',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        blank=True
+    )
+    name = models.CharField(
+        max_length=50
+    )
+    status = models.PositiveSmallIntegerField(
+        choices=POWERFEED_STATUS_CHOICES,
+        default=POWERFEED_STATUS_ACTIVE
+    )
+    type = models.PositiveSmallIntegerField(
+        choices=POWERFEED_TYPE_CHOICES,
+        default=POWERFEED_TYPE_PRIMARY
+    )
+    supply = models.PositiveSmallIntegerField(
+        choices=POWERFEED_SUPPLY_CHOICES,
+        default=POWERFEED_SUPPLY_AC
+    )
+    phase = models.PositiveSmallIntegerField(
+        choices=POWERFEED_PHASE_CHOICES,
+        default=POWERFEED_PHASE_SINGLE
+    )
+    voltage = models.PositiveSmallIntegerField(
+        validators=[MinValueValidator(1)],
+        default=120
+    )
+    amperage = models.PositiveSmallIntegerField(
+        validators=[MinValueValidator(1)],
+        default=20
+    )
+    max_utilization = models.PositiveSmallIntegerField(
+        validators=[MinValueValidator(1), MaxValueValidator(100)],
+        default=80,
+        help_text="Maximum permissible draw (percentage)"
+    )
+    available_power = models.PositiveSmallIntegerField(
+        default=0,
+        editable=False
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to='extras.CustomFieldValue',
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+
+    tags = TaggableManager(through=TaggedItem)
+
+    csv_headers = [
+        'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
+        'amperage', 'max_utilization', 'comments',
+    ]
+
+    class Meta:
+        ordering = ['power_panel', 'name']
+        unique_together = ['power_panel', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:powerfeed', args=[self.pk])
+
+    def to_csv(self):
+        return (
+            self.power_panel.name,
+            self.rack.name if self.rack else None,
+            self.name,
+            self.get_status_display(),
+            self.get_type_display(),
+            self.get_supply_display(),
+            self.get_phase_display(),
+            self.voltage,
+            self.amperage,
+            self.max_utilization,
+            self.comments,
+        )
+
+    def clean(self):
+
+        # Rack must belong to same Site as PowerPanel
+        if self.rack and self.rack.site != self.power_panel.site:
+            raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
+                self.rack, self.rack.site, self.power_panel, self.power_panel.site
+            ))
+
+    def save(self, *args, **kwargs):
+
+        # Cache the available_power property on the instance
+        kva = self.voltage * self.amperage * (self.max_utilization / 100)
+        if self.phase == POWERFEED_PHASE_3PHASE:
+            self.available_power = round(kva * 1.732)
+        else:
+            self.available_power = round(kva)
+
+        super().save(*args, **kwargs)
+
+    def get_type_class(self):
+        return STATUS_CLASSES[self.type]
+
+    def get_status_class(self):
+        return STATUS_CLASSES[self.status]

+ 77 - 10
netbox/dcim/tables.py

@@ -6,8 +6,9 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
 from .models import (
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
-    RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
+    InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 
 
 REGION_LINK = """
 REGION_LINK = """
@@ -144,6 +145,10 @@ STATUS_LABEL = """
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 """
 """
 
 
+TYPE_LABEL = """
+<span class="label label-{{ record.get_type_class }}">{{ record.get_type_display }}</span>
+"""
+
 DEVICE_PRIMARY_IP = """
 DEVICE_PRIMARY_IP = """
 {{ record.primary_ip6.address.ip|default:"" }}
 {{ record.primary_ip6.address.ip|default:"" }}
 {% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
 {% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@@ -184,6 +189,10 @@ CABLE_LENGTH = """
 {% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
 {% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
 """
 """
 
 
+POWERPANEL_POWERFEED_COUNT = """
+<a href="{% url 'dcim:powerfeed_list' %}?power_panel_id={{ record.pk }}">{{ value }}</a>
+"""
+
 
 
 #
 #
 # Regions
 # Regions
@@ -290,12 +299,21 @@ class RackDetailTable(RackTable):
         template_code=RACK_DEVICE_COUNT,
         template_code=RACK_DEVICE_COUNT,
         verbose_name='Devices'
         verbose_name='Devices'
     )
     )
-    get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
+    get_utilization = tables.TemplateColumn(
+        template_code=UTILIZATION_GRAPH,
+        orderable=False,
+        verbose_name='Space'
+    )
+    get_power_utilization = tables.TemplateColumn(
+        template_code=UTILIZATION_GRAPH,
+        orderable=False,
+        verbose_name='Power'
+    )
 
 
     class Meta(RackTable.Meta):
     class Meta(RackTable.Meta):
         fields = (
         fields = (
             'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
             'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
-            'get_utilization',
+            'get_utilization', 'get_power_utilization',
         )
         )
 
 
 
 
@@ -425,7 +443,7 @@ class InterfaceTemplateTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ('pk', 'name', 'mgmt_only', 'form_factor')
+        fields = ('pk', 'name', 'mgmt_only', 'type')
         empty_text = "None"
         empty_text = "None"
 
 
 
 
@@ -582,7 +600,7 @@ class ConsoleServerPortTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
-        fields = ('name',)
+        fields = ('name', 'description')
 
 
 
 
 class PowerPortTable(BaseTable):
 class PowerPortTable(BaseTable):
@@ -596,14 +614,14 @@ class PowerOutletTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerOutlet
         model = PowerOutlet
-        fields = ('name',)
+        fields = ('name', 'description')
 
 
 
 
 class InterfaceTable(BaseTable):
 class InterfaceTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Interface
         model = Interface
-        fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description')
+        fields = ('name', 'type', 'lag', 'enabled', 'mgmt_only', 'description')
 
 
 
 
 class FrontPortTable(BaseTable):
 class FrontPortTable(BaseTable):
@@ -713,7 +731,8 @@ class PowerConnectionTable(BaseTable):
         args=[Accessor('connected_endpoint.device.pk')],
         args=[Accessor('connected_endpoint.device.pk')],
         verbose_name='PDU'
         verbose_name='PDU'
     )
     )
-    connected_endpoint = tables.Column(
+    outlet = tables.Column(
+        accessor=Accessor('_connected_poweroutlet'),
         verbose_name='Outlet'
         verbose_name='Outlet'
     )
     )
     device = tables.LinkColumn(
     device = tables.LinkColumn(
@@ -726,7 +745,7 @@ class PowerConnectionTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = PowerPort
         model = PowerPort
-        fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status')
+        fields = ('pdu', 'outlet', 'device', 'name', 'connection_status')
 
 
 
 
 class InterfaceConnectionTable(BaseTable):
 class InterfaceConnectionTable(BaseTable):
@@ -801,3 +820,51 @@ class VirtualChassisTable(BaseTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VirtualChassis
         model = VirtualChassis
         fields = ('pk', 'master', 'domain', 'member_count', 'actions')
         fields = ('pk', 'master', 'domain', 'member_count', 'actions')
+
+
+#
+# Power panels
+#
+
+class PowerPanelTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')]
+    )
+    powerfeed_count = tables.TemplateColumn(
+        template_code=POWERPANEL_POWERFEED_COUNT,
+        verbose_name='Feeds'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = PowerPanel
+        fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
+
+
+#
+# Power feeds
+#
+
+class PowerFeedTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    power_panel = tables.LinkColumn(
+        viewname='dcim:powerpanel',
+        args=[Accessor('power_panel.pk')],
+    )
+    rack = tables.LinkColumn(
+        viewname='dcim:rack',
+        args=[Accessor('rack.pk')]
+    )
+    status = tables.TemplateColumn(
+        template_code=STATUS_LABEL
+    )
+    type = tables.TemplateColumn(
+        template_code=TYPE_LABEL
+    )
+
+    class Meta(BaseTable.Meta):
+        model = PowerFeed
+        fields = ('pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase')

+ 308 - 27
netbox/dcim/tests/test_api.py

@@ -7,8 +7,8 @@ from dcim.constants import *
 from dcim.models import (
 from dcim.models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer,
-    InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
-    RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
+    InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel,
+    Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
 )
 )
 from ipam.models import IPAddress, VLAN
 from ipam.models import IPAddress, VLAN
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
@@ -47,7 +47,7 @@ class RegionTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['id', 'name', 'site_count', 'slug', 'url']
         )
         )
 
 
     def test_create_region(self):
     def test_create_region(self):
@@ -285,7 +285,7 @@ class RackGroupTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['id', 'name', 'rack_count', 'slug', 'url']
         )
         )
 
 
     def test_create_rackgroup(self):
     def test_create_rackgroup(self):
@@ -393,7 +393,7 @@ class RackRoleTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['id', 'name', 'rack_count', 'slug', 'url']
         )
         )
 
 
     def test_create_rackrole(self):
     def test_create_rackrole(self):
@@ -520,7 +520,7 @@ class RackTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['display_name', 'id', 'name', 'url']
+            ['device_count', 'display_name', 'id', 'name', 'url']
         )
         )
 
 
     def test_create_rack(self):
     def test_create_rack(self):
@@ -746,7 +746,7 @@ class ManufacturerTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['devicetype_count', 'id', 'name', 'slug', 'url']
         )
         )
 
 
     def test_create_manufacturer(self):
     def test_create_manufacturer(self):
@@ -855,7 +855,7 @@ class DeviceTypeTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
+            ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
         )
         )
 
 
     def test_create_devicetype(self):
     def test_create_devicetype(self):
@@ -1569,7 +1569,7 @@ class DeviceRoleTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
         )
         )
 
 
     def test_create_devicerole(self):
     def test_create_devicerole(self):
@@ -1677,7 +1677,7 @@ class PlatformTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
         )
         )
 
 
     def test_create_platform(self):
     def test_create_platform(self):
@@ -1791,6 +1791,16 @@ class DeviceTest(APITestCase):
             site=self.site1,
             site=self.site1,
             cluster=self.cluster1
             cluster=self.cluster1
         )
         )
+        self.device_with_context_data = Device.objects.create(
+            device_type=self.devicetype1,
+            device_role=self.devicerole1,
+            name='Device with context data',
+            site=self.site1,
+            local_context_data={
+                'A': 1,
+                'B': 2
+            }
+        )
 
 
     def test_get_device(self):
     def test_get_device(self):
 
 
@@ -1806,7 +1816,7 @@ class DeviceTest(APITestCase):
         url = reverse('dcim-api:device-list')
         url = reverse('dcim-api:device-list')
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
 
 
-        self.assertEqual(response.data['count'], 3)
+        self.assertEqual(response.data['count'], 4)
 
 
     def test_list_devices_brief(self):
     def test_list_devices_brief(self):
 
 
@@ -1832,7 +1842,7 @@ class DeviceTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(Device.objects.count(), 4)
+        self.assertEqual(Device.objects.count(), 5)
         device4 = Device.objects.get(pk=response.data['id'])
         device4 = Device.objects.get(pk=response.data['id'])
         self.assertEqual(device4.device_type_id, data['device_type'])
         self.assertEqual(device4.device_type_id, data['device_type'])
         self.assertEqual(device4.device_role_id, data['device_role'])
         self.assertEqual(device4.device_role_id, data['device_role'])
@@ -1867,7 +1877,7 @@ class DeviceTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(Device.objects.count(), 6)
+        self.assertEqual(Device.objects.count(), 7)
         self.assertEqual(response.data[0]['name'], data[0]['name'])
         self.assertEqual(response.data[0]['name'], data[0]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['name'])
@@ -1891,7 +1901,7 @@ class DeviceTest(APITestCase):
         response = self.client.put(url, data, format='json', **self.header)
         response = self.client.put(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(Device.objects.count(), 3)
+        self.assertEqual(Device.objects.count(), 4)
         device1 = Device.objects.get(pk=response.data['id'])
         device1 = Device.objects.get(pk=response.data['id'])
         self.assertEqual(device1.device_type_id, data['device_type'])
         self.assertEqual(device1.device_type_id, data['device_type'])
         self.assertEqual(device1.device_role_id, data['device_role'])
         self.assertEqual(device1.device_role_id, data['device_role'])
@@ -1906,7 +1916,21 @@ class DeviceTest(APITestCase):
         response = self.client.delete(url, **self.header)
         response = self.client.delete(url, **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(Device.objects.count(), 2)
+        self.assertEqual(Device.objects.count(), 3)
+
+    def test_config_context_included_by_default_in_list_view(self):
+
+        url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
+
+    def test_config_context_excluded(self):
+
+        url = reverse('dcim-api:device-list') + '?exclude=config_context'
+        response = self.client.get(url, **self.header)
+
+        self.assertFalse('config_context' in response.data['results'][0])
 
 
 
 
 class ConsolePortTest(APITestCase):
 class ConsolePortTest(APITestCase):
@@ -2529,7 +2553,7 @@ class InterfaceTest(APITestCase):
     def test_update_interface(self):
     def test_update_interface(self):
 
 
         lag_interface = Interface.objects.create(
         lag_interface = Interface.objects.create(
-            device=self.device, name='Test LAG Interface', form_factor=IFACE_FF_LAG
+            device=self.device, name='Test LAG Interface', type=IFACE_TYPE_LAG
         )
         )
 
 
         data = {
         data = {
@@ -2817,7 +2841,7 @@ class CableTest(APITestCase):
         )
         )
         for device in [self.device1, self.device2]:
         for device in [self.device1, self.device2]:
             for i in range(0, 10):
             for i in range(0, 10):
-                Interface(device=device, form_factor=IFACE_FF_1GE_FIXED, name='eth{}'.format(i)).save()
+                Interface(device=device, type=IFACE_TYPE_1GE_FIXED, name='eth{}'.format(i)).save()
 
 
         self.cable1 = Cable(
         self.cable1 = Cable(
             termination_a=self.device1.interfaces.get(name='eth0'),
             termination_a=self.device1.interfaces.get(name='eth0'),
@@ -3386,23 +3410,23 @@ class VirtualChassisTest(APITestCase):
             device_type=device_type, device_role=device_role, name='StackSwitch9', site=site
             device_type=device_type, device_role=device_role, name='StackSwitch9', site=site
         )
         )
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device1, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device1, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device2, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device2, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device3, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device3, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device4, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device4, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device5, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device5, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device6, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device6, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device7, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device7, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device8, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device8, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
         for i in range(0, 13):
         for i in range(0, 13):
-            Interface.objects.create(device=self.device9, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
+            Interface.objects.create(device=self.device9, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
 
 
         # Create two VirtualChassis with three members each
         # Create two VirtualChassis with three members each
         self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1')
         self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1')
@@ -3433,7 +3457,7 @@ class VirtualChassisTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'master', 'url']
+            ['id', 'master', 'member_count', 'url']
         )
         )
 
 
     def test_create_virtualchassis(self):
     def test_create_virtualchassis(self):
@@ -3508,3 +3532,260 @@ class VirtualChassisTest(APITestCase):
             self.assertTrue(
             self.assertTrue(
                 Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None)
                 Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None)
             )
             )
+
+
+class PowerPanelTest(APITestCase):
+
+    def setUp(self):
+
+        super().setUp()
+
+        self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
+        self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2')
+        self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3')
+        self.powerpanel1 = PowerPanel.objects.create(
+            site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1'
+        )
+        self.powerpanel2 = PowerPanel.objects.create(
+            site=self.site1, rack_group=self.rackgroup2, name='Test Power Panel 2'
+        )
+        self.powerpanel3 = PowerPanel.objects.create(
+            site=self.site1, rack_group=self.rackgroup3, name='Test Power Panel 3'
+        )
+
+    def test_get_powerpanel(self):
+
+        url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.powerpanel1.name)
+
+    def test_list_powerpanels(self):
+
+        url = reverse('dcim-api:powerpanel-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 3)
+
+    def test_list_powerpanels_brief(self):
+
+        url = reverse('dcim-api:powerpanel-list')
+        response = self.client.get('{}?brief=1'.format(url), **self.header)
+
+        self.assertEqual(
+            sorted(response.data['results'][0]),
+            ['id', 'name', 'powerfeed_count', 'url']
+        )
+
+    def test_create_powerpanel(self):
+
+        data = {
+            'name': 'Test Power Panel 4',
+            'site': self.site1.pk,
+            'rack_group': self.rackgroup1.pk,
+        }
+
+        url = reverse('dcim-api:powerpanel-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(PowerPanel.objects.count(), 4)
+        powerpanel4 = PowerPanel.objects.get(pk=response.data['id'])
+        self.assertEqual(powerpanel4.name, data['name'])
+        self.assertEqual(powerpanel4.site_id, data['site'])
+        self.assertEqual(powerpanel4.rack_group_id, data['rack_group'])
+
+    def test_create_powerpanel_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Power Panel 4',
+                'site': self.site1.pk,
+                'rack_group': self.rackgroup1.pk,
+            },
+            {
+                'name': 'Test Power Panel 5',
+                'site': self.site1.pk,
+                'rack_group': self.rackgroup2.pk,
+            },
+            {
+                'name': 'Test Power Panel 6',
+                'site': self.site1.pk,
+                'rack_group': self.rackgroup3.pk,
+            },
+        ]
+
+        url = reverse('dcim-api:powerpanel-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(PowerPanel.objects.count(), 6)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+
+    def test_update_powerpanel(self):
+
+        data = {
+            'name': 'Test Power Panel X',
+            'rack_group': self.rackgroup2.pk,
+        }
+
+        url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
+        response = self.client.patch(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(PowerPanel.objects.count(), 3)
+        powerpanel1 = PowerPanel.objects.get(pk=response.data['id'])
+        self.assertEqual(powerpanel1.name, data['name'])
+        self.assertEqual(powerpanel1.rack_group_id, data['rack_group'])
+
+    def test_delete_powerpanel(self):
+
+        url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(PowerPanel.objects.count(), 2)
+
+
+class PowerFeedTest(APITestCase):
+
+    def setUp(self):
+
+        super().setUp()
+
+        self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
+        self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
+        self.rack1 = Rack.objects.create(
+            site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
+        )
+        self.rack2 = Rack.objects.create(
+            site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
+        )
+        self.rack3 = Rack.objects.create(
+            site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42,
+        )
+        self.rack4 = Rack.objects.create(
+            site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 4', u_height=42,
+        )
+        self.powerpanel1 = PowerPanel.objects.create(
+            site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1'
+        )
+        self.powerpanel2 = PowerPanel.objects.create(
+            site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 2'
+        )
+        self.powerfeed1 = PowerFeed.objects.create(
+            power_panel=self.powerpanel1, rack=self.rack1, name='Test Power Feed 1A', type=POWERFEED_TYPE_PRIMARY
+        )
+        self.powerfeed2 = PowerFeed.objects.create(
+            power_panel=self.powerpanel2, rack=self.rack1, name='Test Power Feed 1B', type=POWERFEED_TYPE_REDUNDANT
+        )
+        self.powerfeed3 = PowerFeed.objects.create(
+            power_panel=self.powerpanel1, rack=self.rack2, name='Test Power Feed 2A', type=POWERFEED_TYPE_PRIMARY
+        )
+        self.powerfeed4 = PowerFeed.objects.create(
+            power_panel=self.powerpanel2, rack=self.rack2, name='Test Power Feed 2B', type=POWERFEED_TYPE_REDUNDANT
+        )
+        self.powerfeed5 = PowerFeed.objects.create(
+            power_panel=self.powerpanel1, rack=self.rack3, name='Test Power Feed 3A', type=POWERFEED_TYPE_PRIMARY
+        )
+        self.powerfeed6 = PowerFeed.objects.create(
+            power_panel=self.powerpanel2, rack=self.rack3, name='Test Power Feed 3B', type=POWERFEED_TYPE_REDUNDANT
+        )
+
+    def test_get_powerfeed(self):
+
+        url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.powerfeed1.name)
+
+    def test_list_powerfeeds(self):
+
+        url = reverse('dcim-api:powerfeed-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 6)
+
+    def test_list_powerfeeds_brief(self):
+
+        url = reverse('dcim-api:powerfeed-list')
+        response = self.client.get('{}?brief=1'.format(url), **self.header)
+
+        self.assertEqual(
+            sorted(response.data['results'][0]),
+            ['id', 'name', 'url']
+        )
+
+    def test_create_powerfeed(self):
+
+        data = {
+            'name': 'Test Power Feed 4A',
+            'power_panel': self.powerpanel1.pk,
+            'rack': self.rack4.pk,
+            'type': POWERFEED_TYPE_PRIMARY,
+        }
+
+        url = reverse('dcim-api:powerfeed-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(PowerFeed.objects.count(), 7)
+        powerfeed4 = PowerFeed.objects.get(pk=response.data['id'])
+        self.assertEqual(powerfeed4.name, data['name'])
+        self.assertEqual(powerfeed4.power_panel_id, data['power_panel'])
+        self.assertEqual(powerfeed4.rack_id, data['rack'])
+
+    def test_create_powerfeed_bulk(self):
+
+        data = [
+            {
+                'name': 'Test Power Feed 4A',
+                'power_panel': self.powerpanel1.pk,
+                'rack': self.rack4.pk,
+                'type': POWERFEED_TYPE_PRIMARY,
+            },
+            {
+                'name': 'Test Power Feed 4B',
+                'power_panel': self.powerpanel1.pk,
+                'rack': self.rack4.pk,
+                'type': POWERFEED_TYPE_REDUNDANT,
+            },
+        ]
+
+        url = reverse('dcim-api:powerfeed-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(PowerFeed.objects.count(), 8)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+
+    def test_update_powerfeed(self):
+
+        data = {
+            'name': 'Test Power Feed X',
+            'rack': self.rack4.pk,
+            'type': POWERFEED_TYPE_REDUNDANT,
+        }
+
+        url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
+        response = self.client.patch(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(PowerFeed.objects.count(), 6)
+        powerfeed1 = PowerFeed.objects.get(pk=response.data['id'])
+        self.assertEqual(powerfeed1.name, data['name'])
+        self.assertEqual(powerfeed1.rack_id, data['rack'])
+        self.assertEqual(powerfeed1.type, data['type'])
+
+    def test_delete_powerfeed(self):
+
+        url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(PowerFeed.objects.count(), 5)

+ 1 - 1
netbox/dcim/tests/test_models.py

@@ -249,7 +249,7 @@ class CableTestCase(TestCase):
         """
         """
         A cable connection cannot include a virtual interface
         A cable connection cannot include a virtual interface
         """
         """
-        virtual_interface = Interface(device=self.device1, name="V1", form_factor=0)
+        virtual_interface = Interface(device=self.device1, name="V1", type=0)
         cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
         cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             cable.clean()
             cable.clean()

+ 38 - 40
netbox/dcim/tests/test_views.py

@@ -1,21 +1,22 @@
 import urllib.parse
 import urllib.parse
 
 
-from django.contrib.auth import get_user_model
 from django.test import Client, TestCase
 from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
-from dcim.constants import CABLE_TYPE_CAT6, IFACE_FF_1GE_FIXED
+from dcim.constants import CABLE_TYPE_CAT6, IFACE_TYPE_1GE_FIXED
 from dcim.models import (
 from dcim.models import (
     Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
     Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
     RackReservation, RackRole, Site, Region, VirtualChassis,
     RackReservation, RackRole, Site, Region, VirtualChassis,
 )
 )
+from utilities.testing import create_test_user
 
 
 
 
 class RegionTestCase(TestCase):
 class RegionTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_region'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         # Create three Regions
         # Create three Regions
         for i in range(1, 4):
         for i in range(1, 4):
@@ -32,8 +33,9 @@ class RegionTestCase(TestCase):
 class SiteTestCase(TestCase):
 class SiteTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_site'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         region = Region(name='Region 1', slug='region-1')
         region = Region(name='Region 1', slug='region-1')
         region.save()
         region.save()
@@ -64,8 +66,9 @@ class SiteTestCase(TestCase):
 class RackGroupTestCase(TestCase):
 class RackGroupTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackgroup'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -84,11 +87,12 @@ class RackGroupTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class RackTypeTestCase(TestCase):
+class RackRoleTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackrole'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         RackRole.objects.bulk_create([
         RackRole.objects.bulk_create([
             RackRole(name='Rack Role 1', slug='rack-role-1'),
             RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -107,12 +111,9 @@ class RackTypeTestCase(TestCase):
 class RackReservationTestCase(TestCase):
 class RackReservationTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rackreservation'])
         self.client = Client()
         self.client = Client()
-
-        User = get_user_model()
-        user = User(username='testuser', email='testuser@example.com')
-        user.save()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -137,8 +138,9 @@ class RackReservationTestCase(TestCase):
 class RackTestCase(TestCase):
 class RackTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_rack'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -169,8 +171,9 @@ class RackTestCase(TestCase):
 class ManufacturerTypeTestCase(TestCase):
 class ManufacturerTypeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_manufacturer'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         Manufacturer.objects.bulk_create([
         Manufacturer.objects.bulk_create([
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
@@ -189,8 +192,9 @@ class ManufacturerTypeTestCase(TestCase):
 class DeviceTypeTestCase(TestCase):
 class DeviceTypeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_devicetype'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer.save()
         manufacturer.save()
@@ -221,8 +225,9 @@ class DeviceTypeTestCase(TestCase):
 class DeviceRoleTestCase(TestCase):
 class DeviceRoleTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_devicerole'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         DeviceRole.objects.bulk_create([
         DeviceRole.objects.bulk_create([
             DeviceRole(name='Device Role 1', slug='device-role-1'),
             DeviceRole(name='Device Role 1', slug='device-role-1'),
@@ -241,8 +246,9 @@ class DeviceRoleTestCase(TestCase):
 class PlatformTestCase(TestCase):
 class PlatformTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_platform'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         Platform.objects.bulk_create([
         Platform.objects.bulk_create([
             Platform(name='Platform 1', slug='platform-1'),
             Platform(name='Platform 1', slug='platform-1'),
@@ -261,8 +267,9 @@ class PlatformTestCase(TestCase):
 class DeviceTestCase(TestCase):
 class DeviceTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_device'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -303,8 +310,9 @@ class DeviceTestCase(TestCase):
 class InventoryItemTestCase(TestCase):
 class InventoryItemTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_inventoryitem'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -337,18 +345,13 @@ class InventoryItemTestCase(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.assertEqual(response.status_code, 200)
 
 
-    def test_inventoryitem(self):
-
-        inventoryitem = InventoryItem.objects.first()
-        response = self.client.get(inventoryitem.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
 
 
 class CableTestCase(TestCase):
 class CableTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_cable'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -367,17 +370,17 @@ class CableTestCase(TestCase):
         device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole)
         device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole)
         device2.save()
         device2.save()
 
 
-        iface1 = Interface(device=device1, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED)
+        iface1 = Interface(device=device1, name='Interface 1', type=IFACE_TYPE_1GE_FIXED)
         iface1.save()
         iface1.save()
-        iface2 = Interface(device=device1, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED)
+        iface2 = Interface(device=device1, name='Interface 2', type=IFACE_TYPE_1GE_FIXED)
         iface2.save()
         iface2.save()
-        iface3 = Interface(device=device1, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED)
+        iface3 = Interface(device=device1, name='Interface 3', type=IFACE_TYPE_1GE_FIXED)
         iface3.save()
         iface3.save()
-        iface4 = Interface(device=device2, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED)
+        iface4 = Interface(device=device2, name='Interface 1', type=IFACE_TYPE_1GE_FIXED)
         iface4.save()
         iface4.save()
-        iface5 = Interface(device=device2, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED)
+        iface5 = Interface(device=device2, name='Interface 2', type=IFACE_TYPE_1GE_FIXED)
         iface5.save()
         iface5.save()
-        iface6 = Interface(device=device2, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED)
+        iface6 = Interface(device=device2, name='Interface 3', type=IFACE_TYPE_1GE_FIXED)
         iface6.save()
         iface6.save()
 
 
         Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save()
         Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save()
@@ -401,11 +404,12 @@ class CableTestCase(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
 
 
-class VirtualMachineTestCase(TestCase):
+class VirtualChassisTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['dcim.view_virtualchassis'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site.objects.create(name='Site 1', slug='site-1')
         site = Site.objects.create(name='Site 1', slug='site-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
         manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
@@ -450,9 +454,3 @@ class VirtualMachineTestCase(TestCase):
 
 
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
-
-    def test_virtualchassis(self):
-
-        virtualchassis = VirtualChassis.objects.first()
-        response = self.client.get(virtualchassis.get_absolute_url())
-        self.assertEqual(response.status_code, 200)

+ 32 - 8
netbox/dcim/urls.py

@@ -6,7 +6,8 @@ from secrets.views import secret_add
 from . import views
 from . import views
 from .models import (
 from .models import (
     Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
     Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
-    PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
+    PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site,
+    VirtualChassis,
 )
 )
 
 
 app_name = 'dcim'
 app_name = 'dcim'
@@ -162,7 +163,7 @@ urlpatterns = [
     path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
     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/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'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
-    path(r'console-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
+    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>/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>/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/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
@@ -170,8 +171,9 @@ urlpatterns = [
     # 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/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/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'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
-    path(r'console-server-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
+    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>/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>/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/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
@@ -182,7 +184,7 @@ urlpatterns = [
     path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
     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/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'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
-    path(r'power-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
+    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>/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>/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/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
@@ -190,8 +192,9 @@ urlpatterns = [
     # Power outlets
     # Power outlets
     path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
     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/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'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
-    path(r'power-outlets/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
+    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>/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>/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/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
@@ -203,7 +206,7 @@ urlpatterns = [
     path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
     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/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'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    path(r'interfaces/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
+    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>/', views.InterfaceView.as_view(), name='interface'),
     path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     path(r'interfaces/<int:pk>/assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
     path(r'interfaces/<int:pk>/assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
@@ -218,7 +221,7 @@ urlpatterns = [
     path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
     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/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'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
-    path(r'front-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
+    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>/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>/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/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
@@ -230,7 +233,7 @@ urlpatterns = [
     path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
     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/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'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
-    path(r'rear-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
+    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>/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>/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/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
@@ -280,4 +283,25 @@ urlpatterns = [
     path(r'virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
     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(r'virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
 
 
+    # Power panels
+    path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
+    path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
+    path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
+    path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
+    path(r'power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
+    path(r'power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
+    path(r'power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
+    path(r'power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
+
+    # 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}),
+
 ]
 ]

+ 294 - 46
netbox/dcim/views.py

@@ -3,6 +3,7 @@ import re
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import transaction
 from django.db import transaction
 from django.db.models import Count, F
 from django.db.models import Count, F
@@ -10,6 +11,7 @@ from django.forms import modelformset_factory
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.html import escape
+from django.utils.http import is_safe_url
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 
 
@@ -30,8 +32,9 @@ from . import filters, forms, tables
 from .models import (
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
-    RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
+    InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
+    VirtualChassis,
 )
 )
 
 
 
 
@@ -135,7 +138,8 @@ class BulkDisconnectView(GetReturnURLMixin, View):
 # Regions
 # Regions
 #
 #
 
 
-class RegionListView(ObjectListView):
+class RegionListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_region'
     queryset = Region.objects.add_related_count(
     queryset = Region.objects.add_related_count(
         Region.objects.all(),
         Region.objects.all(),
         Site,
         Site,
@@ -179,7 +183,8 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Sites
 # Sites
 #
 #
 
 
-class SiteListView(ObjectListView):
+class SiteListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_site'
     queryset = Site.objects.select_related('region', 'tenant')
     queryset = Site.objects.select_related('region', 'tenant')
     filter = filters.SiteFilter
     filter = filters.SiteFilter
     filter_form = forms.SiteFilterForm
     filter_form = forms.SiteFilterForm
@@ -187,7 +192,8 @@ class SiteListView(ObjectListView):
     template_name = 'dcim/site_list.html'
     template_name = 'dcim/site_list.html'
 
 
 
 
-class SiteView(View):
+class SiteView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_site'
 
 
     def get(self, request, slug):
     def get(self, request, slug):
 
 
@@ -259,7 +265,8 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Rack groups
 # Rack groups
 #
 #
 
 
-class RackGroupListView(ObjectListView):
+class RackGroupListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rackgroup'
     queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
     queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
     filter = filters.RackGroupFilter
     filter = filters.RackGroupFilter
     filter_form = forms.RackGroupFilterForm
     filter_form = forms.RackGroupFilterForm
@@ -297,7 +304,8 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Rack roles
 # Rack roles
 #
 #
 
 
-class RackRoleListView(ObjectListView):
+class RackRoleListView(PermissionRequiredMixin, ObjectListView):
+    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'
     template_name = 'dcim/rackrole_list.html'
@@ -332,7 +340,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Racks
 # Racks
 #
 #
 
 
-class RackListView(ObjectListView):
+class RackListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rack'
     queryset = Rack.objects.select_related(
     queryset = Rack.objects.select_related(
         'site', 'group', 'tenant', 'role'
         'site', 'group', 'tenant', 'role'
     ).prefetch_related(
     ).prefetch_related(
@@ -346,10 +355,11 @@ class RackListView(ObjectListView):
     template_name = 'dcim/rack_list.html'
     template_name = 'dcim/rack_list.html'
 
 
 
 
-class RackElevationListView(View):
+class RackElevationListView(PermissionRequiredMixin, View):
     """
     """
     Display a set of rack elevations side-by-side.
     Display a set of rack elevations side-by-side.
     """
     """
+    permission_required = 'dcim.view_rack'
 
 
     def get(self, request):
     def get(self, request):
 
 
@@ -387,7 +397,8 @@ class RackElevationListView(View):
         })
         })
 
 
 
 
-class RackView(View):
+class RackView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_rack'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -399,10 +410,12 @@ class RackView(View):
         prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
         prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
 
 
         reservations = RackReservation.objects.filter(rack=rack)
         reservations = RackReservation.objects.filter(rack=rack)
+        power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel')
 
 
         return render(request, 'dcim/rack.html', {
         return render(request, 'dcim/rack.html', {
             'rack': rack,
             'rack': rack,
             'reservations': reservations,
             'reservations': reservations,
+            'power_feeds': power_feeds,
             'nonracked_devices': nonracked_devices,
             'nonracked_devices': nonracked_devices,
             'next_rack': next_rack,
             'next_rack': next_rack,
             'prev_rack': prev_rack,
             'prev_rack': prev_rack,
@@ -457,7 +470,8 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Rack reservations
 # Rack reservations
 #
 #
 
 
-class RackReservationListView(ObjectListView):
+class RackReservationListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_rackreservation'
     queryset = RackReservation.objects.select_related('rack__site')
     queryset = RackReservation.objects.select_related('rack__site')
     filter = filters.RackReservationFilter
     filter = filters.RackReservationFilter
     filter_form = forms.RackReservationFilterForm
     filter_form = forms.RackReservationFilterForm
@@ -513,7 +527,8 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Manufacturers
 # Manufacturers
 #
 #
 
 
-class ManufacturerListView(ObjectListView):
+class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_manufacturer'
     queryset = Manufacturer.objects.annotate(
     queryset = Manufacturer.objects.annotate(
         devicetype_count=Count('device_types', distinct=True),
         devicetype_count=Count('device_types', distinct=True),
         inventoryitem_count=Count('inventory_items', distinct=True),
         inventoryitem_count=Count('inventory_items', distinct=True),
@@ -552,7 +567,8 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device types
 # Device types
 #
 #
 
 
-class DeviceTypeListView(ObjectListView):
+class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_devicetype'
     queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
     queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
     filter = filters.DeviceTypeFilter
     filter = filters.DeviceTypeFilter
     filter_form = forms.DeviceTypeFilterForm
     filter_form = forms.DeviceTypeFilterForm
@@ -560,7 +576,8 @@ class DeviceTypeListView(ObjectListView):
     template_name = 'dcim/devicetype_list.html'
     template_name = 'dcim/devicetype_list.html'
 
 
 
 
-class DeviceTypeView(View):
+class DeviceTypeView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_devicetype'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -816,7 +833,8 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Device roles
 # Device roles
 #
 #
 
 
-class DeviceRoleListView(ObjectListView):
+class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
+    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'
     template_name = 'dcim/devicerole_list.html'
@@ -851,7 +869,8 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Platforms
 # Platforms
 #
 #
 
 
-class PlatformListView(ObjectListView):
+class PlatformListView(PermissionRequiredMixin, ObjectListView):
+    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'
     template_name = 'dcim/platform_list.html'
@@ -886,7 +905,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Devices
 # Devices
 #
 #
 
 
-class DeviceListView(ObjectListView):
+class DeviceListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_device'
     queryset = Device.objects.select_related(
     queryset = Device.objects.select_related(
         'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
         'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
     )
     )
@@ -896,7 +916,8 @@ class DeviceListView(ObjectListView):
     template_name = 'dcim/device_list.html'
     template_name = 'dcim/device_list.html'
 
 
 
 
-class DeviceView(View):
+class DeviceView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_device'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -919,10 +940,10 @@ class DeviceView(View):
         consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable')
         consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable')
 
 
         # Power ports
         # Power ports
-        power_ports = device.powerports.select_related('connected_endpoint__device', 'cable')
+        power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable')
 
 
         # Power outlets
         # Power outlets
-        poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable')
+        poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable', 'power_port')
 
 
         # Interfaces
         # Interfaces
         interfaces = device.vc_interfaces.select_related(
         interfaces = device.vc_interfaces.select_related(
@@ -976,7 +997,8 @@ class DeviceView(View):
         })
         })
 
 
 
 
-class DeviceInventoryView(View):
+class DeviceInventoryView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_device'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -997,7 +1019,7 @@ class DeviceInventoryView(View):
 
 
 
 
 class DeviceStatusView(PermissionRequiredMixin, View):
 class DeviceStatusView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1010,7 +1032,7 @@ class DeviceStatusView(PermissionRequiredMixin, View):
 
 
 
 
 class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
 class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1027,7 +1049,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
 
 
 
 
 class DeviceConfigView(PermissionRequiredMixin, View):
 class DeviceConfigView(PermissionRequiredMixin, View):
-    permission_required = 'dcim.napalm_read'
+    permission_required = ('dcim.view_device', 'dcim.napalm_read')
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1039,7 +1061,8 @@ class DeviceConfigView(PermissionRequiredMixin, View):
         })
         })
 
 
 
 
-class DeviceConfigContextView(ObjectConfigContextView):
+class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
+    permission_required = 'dcim.view_device'
     object_class = Device
     object_class = Device
     base_template = 'dcim/device.html'
     base_template = 'dcim/device.html'
 
 
@@ -1163,6 +1186,14 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = ConsoleServerPort
     model = ConsoleServerPort
 
 
 
 
+class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_consoleserverport'
+    queryset = ConsoleServerPort.objects.all()
+    parent_model = Device
+    table = tables.ConsoleServerPortTable
+    form = forms.ConsoleServerPortBulkEditForm
+
+
 class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
 class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
     permission_required = 'dcim.change_consoleserverport'
     permission_required = 'dcim.change_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
@@ -1239,6 +1270,14 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     model = PowerOutlet
     model = PowerOutlet
 
 
 
 
+class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_poweroutlet'
+    queryset = PowerOutlet.objects.all()
+    parent_model = Device
+    table = tables.PowerOutletTable
+    form = forms.PowerOutletBulkEditForm
+
+
 class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
 class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
     permission_required = 'dcim.change_poweroutlet'
     permission_required = 'dcim.change_poweroutlet'
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
@@ -1262,7 +1301,8 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Interfaces
 # Interfaces
 #
 #
 
 
-class InterfaceView(View):
+class InterfaceView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_interface'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1643,7 +1683,8 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
 # Cables
 # Cables
 #
 #
 
 
-class CableListView(ObjectListView):
+class CableListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_cable'
     queryset = Cable.objects.prefetch_related(
     queryset = Cable.objects.prefetch_related(
         'termination_a', 'termination_b'
         'termination_a', 'termination_b'
     )
     )
@@ -1653,7 +1694,8 @@ class CableListView(ObjectListView):
     template_name = 'dcim/cable_list.html'
     template_name = 'dcim/cable_list.html'
 
 
 
 
-class CableView(View):
+class CableView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_cable'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -1664,10 +1706,11 @@ class CableView(View):
         })
         })
 
 
 
 
-class CableTraceView(View):
+class CableTraceView(PermissionRequiredMixin, View):
     """
     """
     Trace a cable path beginning from the given termination.
     Trace a cable path beginning from the given termination.
     """
     """
+    permission_required = 'dcim.view_cable'
 
 
     def get(self, request, model, pk):
     def get(self, request, model, pk):
 
 
@@ -1679,20 +1722,80 @@ class CableTraceView(View):
         })
         })
 
 
 
 
-class CableCreateView(PermissionRequiredMixin, ObjectEditView):
+class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
     permission_required = 'dcim.add_cable'
     permission_required = 'dcim.add_cable'
-    model = Cable
-    model_form = forms.CableCreateForm
     template_name = 'dcim/cable_connect.html'
     template_name = 'dcim/cable_connect.html'
 
 
-    def alter_obj(self, obj, request, url_args, url_kwargs):
+    def dispatch(self, request, *args, **kwargs):
 
 
-        # Retrieve endpoint A based on the given type and PK
-        termination_a_type = url_kwargs.get('termination_a_type')
-        termination_a_id = url_kwargs.get('termination_a_id')
-        obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
+        termination_a_type = kwargs.get('termination_a_type')
+        termination_a_id = kwargs.get('termination_a_id')
 
 
-        return obj
+        termination_b_type_name = kwargs.get('termination_b_type')
+        self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', ''))
+
+        self.obj = Cable(
+            termination_a=termination_a_type.objects.get(pk=termination_a_id),
+            termination_b_type=self.termination_b_type
+        )
+        self.form_class = {
+            'console-port': forms.ConnectCableToConsolePortForm,
+            'console-server-port': forms.ConnectCableToConsoleServerPortForm,
+            'power-port': forms.ConnectCableToPowerPortForm,
+            'power-outlet': forms.ConnectCableToPowerOutletForm,
+            'interface': forms.ConnectCableToInterfaceForm,
+            'front-port': forms.ConnectCableToFrontPortForm,
+            'rear-port': forms.ConnectCableToRearPortForm,
+            'power-feed': forms.ConnectCableToPowerFeedForm,
+            'circuit-termination': forms.ConnectCableToCircuitTerminationForm,
+        }[termination_b_type_name]
+
+        return super().dispatch(request, *args, **kwargs)
+
+    def get(self, request, *args, **kwargs):
+
+        # Parse initial data manually to avoid setting field values as lists
+        initial_data = {k: request.GET[k] for k in request.GET}
+
+        form = self.form_class(instance=self.obj, initial=initial_data)
+
+        return render(request, self.template_name, {
+            'obj': self.obj,
+            'obj_type': Cable._meta.verbose_name,
+            'termination_b_type': self.termination_b_type.name,
+            'form': form,
+            'return_url': self.get_return_url(request, self.obj),
+        })
+
+    def post(self, request, *args, **kwargs):
+
+        form = self.form_class(request.POST, request.FILES, instance=self.obj)
+
+        if form.is_valid():
+            obj = form.save()
+
+            msg = 'Created cable <a href="{}">{}</a>'.format(
+                obj.get_absolute_url(),
+                escape(obj)
+            )
+            messages.success(request, mark_safe(msg))
+
+            if '_addanother' in request.POST:
+                return redirect(request.get_full_path())
+
+            return_url = form.cleaned_data.get('return_url')
+            if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
+                return redirect(return_url)
+            else:
+                return redirect(self.get_return_url(request, obj))
+
+        return render(request, self.template_name, {
+            'obj': self.obj,
+            'obj_type': Cable._meta.verbose_name,
+            'termination_b_type': self.termination_b_type.name,
+            'form': form,
+            'return_url': self.get_return_url(request, self.obj),
+        })
 
 
 
 
 class CableEditView(PermissionRequiredMixin, ObjectEditView):
 class CableEditView(PermissionRequiredMixin, ObjectEditView):
@@ -1737,7 +1840,8 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Connections
 # Connections
 #
 #
 
 
-class ConsoleConnectionsListView(ObjectListView):
+class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
     queryset = ConsolePort.objects.select_related(
     queryset = ConsolePort.objects.select_related(
         'device', 'connected_endpoint__device'
         'device', 'connected_endpoint__device'
     ).filter(
     ).filter(
@@ -1767,13 +1871,14 @@ class ConsoleConnectionsListView(ObjectListView):
         return csv_data
         return csv_data
 
 
 
 
-class PowerConnectionsListView(ObjectListView):
+class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
     queryset = PowerPort.objects.select_related(
     queryset = PowerPort.objects.select_related(
-        'device', 'connected_endpoint__device'
+        'device', '_connected_poweroutlet__device'
     ).filter(
     ).filter(
-        connected_endpoint__isnull=False
+        _connected_poweroutlet__isnull=False
     ).order_by(
     ).order_by(
-        'cable', 'connected_endpoint__device__name', 'connected_endpoint__name'
+        'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name'
     )
     )
     filter = filters.PowerConnectionFilter
     filter = filters.PowerConnectionFilter
     filter_form = forms.PowerConnectionFilterForm
     filter_form = forms.PowerConnectionFilterForm
@@ -1797,7 +1902,8 @@ class PowerConnectionsListView(ObjectListView):
         return csv_data
         return csv_data
 
 
 
 
-class InterfaceConnectionsListView(ObjectListView):
+class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.interface'
     queryset = Interface.objects.select_related(
     queryset = Interface.objects.select_related(
         'device', 'cable', '_connected_interface__device'
         'device', 'cable', '_connected_interface__device'
     ).filter(
     ).filter(
@@ -1839,7 +1945,8 @@ class InterfaceConnectionsListView(ObjectListView):
 # Inventory items
 # Inventory items
 #
 #
 
 
-class InventoryItemListView(ObjectListView):
+class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_inventoryitem'
     queryset = InventoryItem.objects.select_related('device', 'manufacturer')
     queryset = InventoryItem.objects.select_related('device', 'manufacturer')
     filter = filters.InventoryItemFilter
     filter = filters.InventoryItemFilter
     filter_form = forms.InventoryItemFilterForm
     filter_form = forms.InventoryItemFilterForm
@@ -1894,7 +2001,8 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Virtual chassis
 # Virtual chassis
 #
 #
 
 
-class VirtualChassisListView(ObjectListView):
+class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_virtualchassis'
     queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members'))
     queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members'))
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
     filter = filters.VirtualChassisFilter
     filter = filters.VirtualChassisFilter
@@ -2123,3 +2231,143 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
             'form': form,
             'form': form,
             'return_url': self.get_return_url(request, device),
             'return_url': self.get_return_url(request, device),
         })
         })
+
+
+#
+# Power panels
+#
+
+class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_powerpanel'
+    queryset = PowerPanel.objects.select_related(
+        'site', 'rack_group'
+    ).annotate(
+        powerfeed_count=Count('powerfeeds')
+    )
+    filter = filters.PowerPanelFilter
+    filter_form = forms.PowerPanelFilterForm
+    table = tables.PowerPanelTable
+    template_name = 'dcim/powerpanel_list.html'
+
+
+class PowerPanelView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_powerpanel'
+
+    def get(self, request, pk):
+
+        powerpanel = get_object_or_404(PowerPanel.objects.select_related('site', 'rack_group'), pk=pk)
+        powerfeed_table = tables.PowerFeedTable(
+            data=PowerFeed.objects.filter(power_panel=powerpanel).select_related('rack'),
+            orderable=False
+        )
+        powerfeed_table.exclude = ['power_panel']
+
+        return render(request, 'dcim/powerpanel.html', {
+            'powerpanel': powerpanel,
+            'powerfeed_table': powerfeed_table,
+        })
+
+
+class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_powerpanel'
+    model = PowerPanel
+    model_form = forms.PowerPanelForm
+    default_return_url = 'dcim:powerpanel_list'
+
+
+class PowerPanelEditView(PowerPanelCreateView):
+    permission_required = 'dcim.change_powerpanel'
+
+
+class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_powerpanel'
+    model = PowerPanel
+    default_return_url = 'dcim:powerpanel_list'
+
+
+class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_powerpanel'
+    model_form = forms.PowerPanelCSVForm
+    table = tables.PowerPanelTable
+    default_return_url = 'dcim:powerpanel_list'
+
+
+class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_powerpanel'
+    queryset = PowerPanel.objects.select_related(
+        'site', 'rack_group'
+    ).annotate(
+        rack_count=Count('powerfeeds')
+    )
+    filter = filters.PowerPanelFilter
+    table = tables.PowerPanelTable
+    default_return_url = 'dcim:powerpanel_list'
+
+
+#
+# Power feeds
+#
+
+class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'dcim.view_powerfeed'
+    queryset = PowerFeed.objects.select_related(
+        'power_panel', 'rack'
+    )
+    filter = filters.PowerFeedFilter
+    filter_form = forms.PowerFeedFilterForm
+    table = tables.PowerFeedTable
+    template_name = 'dcim/powerfeed_list.html'
+
+
+class PowerFeedView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_powerfeed'
+
+    def get(self, request, pk):
+
+        powerfeed = get_object_or_404(PowerFeed.objects.select_related('power_panel', 'rack'), pk=pk)
+
+        return render(request, 'dcim/powerfeed.html', {
+            'powerfeed': powerfeed,
+        })
+
+
+class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.add_powerfeed'
+    model = PowerFeed
+    model_form = forms.PowerFeedForm
+    template_name = 'dcim/powerfeed_edit.html'
+    default_return_url = 'dcim:powerfeed_list'
+
+
+class PowerFeedEditView(PowerFeedCreateView):
+    permission_required = 'dcim.change_powerfeed'
+
+
+class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_powerfeed'
+    model = PowerFeed
+    default_return_url = 'dcim:powerfeed_list'
+
+
+class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_powerfeed'
+    model_form = forms.PowerFeedCSVForm
+    table = tables.PowerFeedTable
+    default_return_url = 'dcim:powerfeed_list'
+
+
+class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_powerfeed'
+    queryset = PowerFeed.objects.select_related('power_panel', 'rack')
+    filter = filters.PowerFeedFilter
+    table = tables.PowerFeedTable
+    form = forms.PowerFeedBulkEditForm
+    default_return_url = 'dcim:powerfeed_list'
+
+
+class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_powerfeed'
+    queryset = PowerFeed.objects.select_related('power_panel', 'rack')
+    filter = filters.PowerFeedFilter
+    table = tables.PowerFeedTable
+    default_return_url = 'dcim:powerfeed_list'

+ 29 - 1
netbox/extras/admin.py

@@ -3,7 +3,7 @@ from django.contrib import admin
 
 
 from netbox.admin import admin_site
 from netbox.admin import admin_site
 from utilities.forms import LaxURLField
 from utilities.forms import LaxURLField
-from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, Webhook
+from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
 
 
 
 
 def order_content_types(field):
 def order_content_types(field):
@@ -77,6 +77,34 @@ class CustomFieldAdmin(admin.ModelAdmin):
         return ', '.join([ct.name for ct in obj.obj_type.all()])
         return ', '.join([ct.name for ct in obj.obj_type.all()])
 
 
 
 
+#
+# Custom links
+#
+
+class CustomLinkForm(forms.ModelForm):
+
+    class Meta:
+        model = CustomLink
+        exclude = []
+        help_texts = {
+            'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>.',
+            'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Format ContentType choices
+        order_content_types(self.fields['content_type'])
+        self.fields['content_type'].choices.insert(0, ('', '---------'))
+
+
+@admin.register(CustomLink, site=admin_site)
+class CustomLinkAdmin(admin.ModelAdmin):
+    list_display = ['name', 'content_type', 'group_name', 'weight']
+    form = CustomLinkForm
+
+
 #
 #
 # Graphs
 # Graphs
 #
 #

+ 5 - 2
netbox/extras/api/serializers.py

@@ -1,7 +1,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
+from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
-from taggit.models import Tag
 
 
 from dcim.api.nested_serializers import (
 from dcim.api.nested_serializers import (
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
     NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
@@ -11,6 +11,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
 from extras.constants import *
 from extras.constants import *
 from extras.models import (
 from extras.models import (
     ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
     ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    Tag
 )
 )
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
@@ -89,7 +90,7 @@ class TagSerializer(ValidatedModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
-        fields = ['id', 'name', 'slug', 'tagged_items']
+        fields = ['id', 'name', 'slug', 'color', 'comments', 'tagged_items']
 
 
 
 
 #
 #
@@ -123,6 +124,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
 
 
         return data
         return data
 
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_parent(self, obj):
     def get_parent(self, obj):
 
 
         # Static mapping of models to their nested serializers
         # Static mapping of models to their nested serializers
@@ -237,6 +239,7 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
             'object_data',
             'object_data',
         ]
         ]
 
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_changed_object(self, obj):
     def get_changed_object(self, obj):
         """
         """
         Serialize a nested representation of the changed object.
         Serialize a nested representation of the changed object.

+ 3 - 0
netbox/extras/api/urls.py

@@ -17,6 +17,9 @@ router.APIRootView = ExtrasRootView
 # Field choices
 # Field choices
 router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
 router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
 
 
+# Custom field choices
+router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice')
+
 # Graphs
 # Graphs
 router.register(r'graphs', views.GraphViewSet)
 router.register(r'graphs', views.GraphViewSet)
 
 

+ 35 - 3
netbox/extras/api/views.py

@@ -1,3 +1,5 @@
+from collections import OrderedDict
+
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count
 from django.db.models import Count
 from django.http import Http404, HttpResponse
 from django.http import Http404, HttpResponse
@@ -6,11 +8,11 @@ from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
 from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
-from taggit.models import Tag
 
 
 from extras import filters
 from extras import filters
 from extras.models import (
 from extras.models import (
-    ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    Tag,
 )
 )
 from extras.reports import get_report, get_reports
 from extras.reports import get_report, get_reports
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -29,6 +31,36 @@ class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
     )
     )
 
 
 
 
+#
+# Custom field choices
+#
+
+class CustomFieldChoicesViewSet(ViewSet):
+    """
+    """
+    permission_classes = [IsAuthenticatedOrLoginNotRequired]
+
+    def __init__(self, *args, **kwargs):
+        super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs)
+
+        self._fields = OrderedDict()
+
+        for cfc in CustomFieldChoice.objects.all():
+            self._fields.setdefault(cfc.field.name, {})
+            self._fields[cfc.field.name][cfc.value] = cfc.pk
+
+    def list(self, request):
+        return Response(self._fields)
+
+    def retrieve(self, request, pk):
+        if pk not in self._fields:
+            raise Http404
+        return Response(self._fields[pk])
+
+    def get_view_name(self):
+        return "Custom Field choices"
+
+
 #
 #
 # Custom fields
 # Custom fields
 #
 #
@@ -117,7 +149,7 @@ class TopologyMapViewSet(ModelViewSet):
 
 
 class TagViewSet(ModelViewSet):
 class TagViewSet(ModelViewSet):
     queryset = Tag.objects.annotate(
     queryset = Tag.objects.annotate(
-        tagged_items=Count('taggit_taggeditem_items', distinct=True)
+        tagged_items=Count('extras_taggeditem_items', distinct=True)
     )
     )
     serializer_class = serializers.TagSerializer
     serializer_class = serializers.TagSerializer
     filterset_class = filters.TagFilter
     filterset_class = filters.TagFilter

+ 3 - 0
netbox/extras/apps.py

@@ -7,6 +7,9 @@ class ExtrasConfig(AppConfig):
     name = "extras"
     name = "extras"
 
 
     def ready(self):
     def ready(self):
+
+        import extras.signals
+
         # Check that we can connect to the configured Redis database if webhooks are enabled.
         # Check that we can connect to the configured Redis database if webhooks are enabled.
         if settings.WEBHOOKS_ENABLED:
         if settings.WEBHOOKS_ENABLED:
             try:
             try:

+ 118 - 25
netbox/extras/constants.py

@@ -1,13 +1,24 @@
 
 
 # Models which support custom fields
 # Models which support custom fields
-CUSTOMFIELD_MODELS = (
-    'provider', 'circuit',                                         # Circuits
-    'site', 'rack', 'devicetype', 'device',                        # DCIM
-    'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service',  # IPAM
-    'secret',                                                      # Secrets
-    'tenant',                                                      # Tenancy
-    'cluster', 'virtualmachine',                                   # Virtualization
-)
+CUSTOMFIELD_MODELS = [
+    'circuits.circuit',
+    'circuits.provider',
+    'dcim.device',
+    'dcim.devicetype',
+    'dcim.powerfeed',
+    'dcim.rack',
+    'dcim.site',
+    'ipam.aggregate',
+    'ipam.ipaddress',
+    'ipam.prefix',
+    'ipam.service',
+    'ipam.vlan',
+    'ipam.vrf',
+    'secrets.secret',
+    'tenancy.tenant',
+    'virtualization.cluster',
+    'virtualization.virtualmachine',
+]
 
 
 # Custom field types
 # Custom field types
 CF_TYPE_TEXT = 100
 CF_TYPE_TEXT = 100
@@ -35,6 +46,46 @@ CF_FILTER_CHOICES = (
     (CF_FILTER_EXACT, 'Exact'),
     (CF_FILTER_EXACT, 'Exact'),
 )
 )
 
 
+# Custom links
+CUSTOMLINK_MODELS = [
+    'circuits.circuit',
+    'circuits.provider',
+    'dcim.cable',
+    'dcim.device',
+    'dcim.devicetype',
+    'dcim.powerpanel',
+    'dcim.powerfeed',
+    'dcim.rack',
+    'dcim.site',
+    'ipam.aggregate',
+    'ipam.ipaddress',
+    'ipam.prefix',
+    'ipam.service',
+    'ipam.vlan',
+    'ipam.vrf',
+    'secrets.secret',
+    'tenancy.tenant',
+    'virtualization.cluster',
+    'virtualization.virtualmachine',
+]
+
+BUTTON_CLASS_DEFAULT = 'default'
+BUTTON_CLASS_PRIMARY = 'primary'
+BUTTON_CLASS_SUCCESS = 'success'
+BUTTON_CLASS_INFO = 'info'
+BUTTON_CLASS_WARNING = 'warning'
+BUTTON_CLASS_DANGER = 'danger'
+BUTTON_CLASS_LINK = 'link'
+BUTTON_CLASS_CHOICES = (
+    (BUTTON_CLASS_DEFAULT, 'Default'),
+    (BUTTON_CLASS_PRIMARY, 'Primary (blue)'),
+    (BUTTON_CLASS_SUCCESS, 'Success (green)'),
+    (BUTTON_CLASS_INFO, 'Info (aqua)'),
+    (BUTTON_CLASS_WARNING, 'Warning (orange)'),
+    (BUTTON_CLASS_DANGER, 'Danger (red)'),
+    (BUTTON_CLASS_LINK, 'None (link)'),
+)
+
 # Graph types
 # Graph types
 GRAPH_TYPE_INTERFACE = 100
 GRAPH_TYPE_INTERFACE = 100
 GRAPH_TYPE_PROVIDER = 200
 GRAPH_TYPE_PROVIDER = 200
@@ -47,13 +98,32 @@ GRAPH_TYPE_CHOICES = (
 
 
 # Models which support export templates
 # Models which support export templates
 EXPORTTEMPLATE_MODELS = [
 EXPORTTEMPLATE_MODELS = [
-    'provider', 'circuit',                                                          # Circuits
-    'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device',  # DCIM
-    'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis',             # DCIM
-    'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service',                   # IPAM
-    'secret',                                                                       # Secrets
-    'tenant',                                                                       # Tenancy
-    'cluster', 'virtualmachine',                                                    # Virtualization
+    'circuits.circuit',
+    'circuits.provider',
+    'dcim.cable',
+    'dcim.consoleport',
+    'dcim.device',
+    'dcim.devicetype',
+    'dcim.interface',
+    'dcim.manufacturer',
+    'dcim.powerpanel',
+    'dcim.powerport',
+    'dcim.powerfeed',
+    'dcim.rack',
+    'dcim.rackgroup',
+    'dcim.region',
+    'dcim.site',
+    'dcim.virtualchassis',
+    'ipam.aggregate',
+    'ipam.ipaddress',
+    'ipam.prefix',
+    'ipam.service',
+    'ipam.vlan',
+    'ipam.vrf',
+    'secrets.secret',
+    'tenancy.tenant',
+    'virtualization.cluster',
+    'virtualization.virtualmachine',
 ]
 ]
 
 
 # ExportTemplate language choices
 # ExportTemplate language choices
@@ -125,13 +195,36 @@ WEBHOOK_CT_CHOICES = (
 )
 )
 
 
 # Models which support registered webhooks
 # Models which support registered webhooks
-WEBHOOK_MODELS = (
-    'provider', 'circuit',                                           # Circuits
-    'site', 'rack', 'devicetype', 'device', 'virtualchassis',        # DCIM
-    'consoleport', 'consoleserverport', 'powerport', 'poweroutlet',
-    'interface', 'devicebay', 'inventoryitem',
-    'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service',    # IPAM
-    'secret',                                                        # Secrets
-    'tenant',                                                        # Tenancy
-    'cluster', 'virtualmachine',                                     # Virtualization
-)
+WEBHOOK_MODELS = [
+    'circuits.circuit',
+    'circuits.provider',
+    'dcim.cable',
+    'dcim.consoleport',
+    'dcim.consoleserverport',
+    'dcim.device',
+    'dcim.devicebay',
+    'dcim.devicetype',
+    'dcim.interface',
+    'dcim.inventoryitem',
+    'dcim.frontport',
+    'dcim.manufacturer',
+    'dcim.poweroutlet',
+    'dcim.powerpanel',
+    'dcim.powerport',
+    'dcim.powerfeed',
+    'dcim.rack',
+    'dcim.rearport',
+    'dcim.region',
+    'dcim.site',
+    'dcim.virtualchassis',
+    'ipam.aggregate',
+    'ipam.ipaddress',
+    'ipam.prefix',
+    'ipam.service',
+    'ipam.vlan',
+    'ipam.vrf',
+    'secrets.secret',
+    'tenancy.tenant',
+    'virtualization.cluster',
+    'virtualization.virtualmachine',
+]

+ 1 - 2
netbox/extras/filters.py

@@ -1,12 +1,11 @@
 import django_filters
 import django_filters
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.db.models import Q
-from taggit.models import Tag
 
 
 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 .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
 from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
-from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap
+from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
 
 
 
 
 class CustomFieldFilter(django_filters.Filter):
 class CustomFieldFilter(django_filters.Filter):

+ 5 - 5
netbox/extras/forms.py

@@ -5,19 +5,18 @@ 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 django.core.exceptions import ObjectDoesNotExist
 from taggit.forms import TagField
 from taggit.forms import TagField
-from taggit.models import Tag
 
 
 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, ContentTypeSelect,
-    FilterChoiceField, LaxURLField, JSONField, SlugField,
+    add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField,
+    ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField,
 )
 )
 from .constants import (
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     OBJECTCHANGE_ACTION_CHOICES,
     OBJECTCHANGE_ACTION_CHOICES,
 )
 )
-from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange
+from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
 
 
 
 
 #
 #
@@ -189,11 +188,12 @@ class CustomFieldFilterForm(forms.Form):
 
 
 class TagForm(BootstrapMixin, forms.ModelForm):
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
+    comments = CommentField()
 
 
     class Meta:
     class Meta:
         model = Tag
         model = Tag
         fields = [
         fields = [
-            'name', 'slug',
+            'name', 'slug', 'color', 'comments'
         ]
         ]
 
 
 
 

+ 4 - 11
netbox/extras/management/commands/nbshell.py

@@ -6,7 +6,6 @@ from django import get_version
 from django.apps import apps
 from django.apps import apps
 from django.conf import settings
 from django.conf import settings
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
-from django.db.models import Model
 
 
 APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
 APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
 
 
@@ -38,16 +37,10 @@ class Command(BaseCommand):
         for app in APPS:
         for app in APPS:
             self.django_models[app] = []
             self.django_models[app] = []
 
 
-            # Models
-            app_models = sys.modules['{}.models'.format(app)]
-            for name in dir(app_models):
-                model = getattr(app_models, name)
-                try:
-                    if issubclass(model, Model) and model._meta.app_label == app:
-                        namespace[name] = model
-                        self.django_models[app].append(name)
-                except TypeError:
-                    pass
+            # Load models from each app
+            for model in apps.get_app_config(app).get_models():
+                namespace[model.__name__] = model
+                self.django_models[app].append(model.__name__)
 
 
             # Constants
             # Constants
             try:
             try:

+ 13 - 1
netbox/extras/middleware.py

@@ -7,6 +7,7 @@ from django.conf import settings
 from django.db.models.signals import post_delete, post_save
 from django.db.models.signals import post_delete, post_save
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.functional import curry
 from django.utils.functional import curry
+from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 
 from extras.webhooks import enqueue_webhooks
 from extras.webhooks import enqueue_webhooks
 from .constants import (
 from .constants import (
@@ -33,15 +34,20 @@ def _record_object_deleted(request, instance, **kwargs):
     if hasattr(instance, 'log_change'):
     if hasattr(instance, 'log_change'):
         instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
         instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
 
 
+    # Enqueue webhooks
     enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
     enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
 
 
+    # Increment metric counters
+    model_deletes.labels(instance._meta.model_name).inc()
+
 
 
 class ObjectChangeMiddleware(object):
 class ObjectChangeMiddleware(object):
     """
     """
-    This middleware performs two functions in response to an object being created, updated, or deleted:
+    This middleware performs three functions in response to an object being created, updated, or deleted:
 
 
         1. Create an ObjectChange to reflect the modification to the object in the changelog.
         1. Create an ObjectChange to reflect the modification to the object in the changelog.
         2. Enqueue any relevant webhooks.
         2. Enqueue any relevant webhooks.
+        3. Increment metric counter for the event type
 
 
     The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit
     The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit
     differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
     differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
@@ -81,6 +87,12 @@ class ObjectChangeMiddleware(object):
             # Enqueue webhooks
             # Enqueue webhooks
             enqueue_webhooks(obj, request.user, request.id, action)
             enqueue_webhooks(obj, request.user, request.id, action)
 
 
+            # Increment metric counters
+            if action == OBJECTCHANGE_ACTION_CREATE:
+                model_inserts.labels(obj._meta.model_name).inc()
+            elif action == OBJECTCHANGE_ACTION_UPDATE:
+                model_updates.labels(obj._meta.model_name).inc()
+
         # Housekeeping: 1% chance of clearing out expired ObjectChanges
         # Housekeeping: 1% chance of clearing out expired ObjectChanges
         if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
         if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
             cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
             cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)

+ 43 - 0
netbox/extras/migrations/0019_tag_taggeditem.py

@@ -0,0 +1,43 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0018_exporttemplate_add_jinja2'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Tag',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='TaggedItem',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('object_id', models.IntegerField(db_index=True)),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_tagged_items', to='contenttypes.ContentType')),
+                ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_items', to='extras.Tag')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+        migrations.AlterIndexTogether(
+            name='taggeditem',
+            index_together={('content_type', 'object_id')},
+        ),
+    ]

+ 65 - 0
netbox/extras/migrations/0020_tag_data.py

@@ -0,0 +1,65 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+import utilities.fields
+
+
+def copy_tags(apps, schema_editor):
+    """
+    Copy data from taggit_tag to extras_tag
+    """
+    TaggitTag = apps.get_model('taggit', 'Tag')
+    ExtrasTag = apps.get_model('extras', 'Tag')
+
+    tags_values = TaggitTag.objects.all().values('id', 'name', 'slug')
+    tags = [ExtrasTag(**tag) for tag in tags_values]
+    ExtrasTag.objects.bulk_create(tags)
+
+
+def copy_taggeditems(apps, schema_editor):
+    """
+    Copy data from taggit_taggeditem to extras_taggeditem
+    """
+    TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
+    ExtrasTaggedItem = apps.get_model('extras', 'TaggedItem')
+
+    tagged_items_values = TaggitTaggedItem.objects.all().values('id', 'object_id', 'content_type_id', 'tag_id')
+    tagged_items = [ExtrasTaggedItem(**tagged_item) for tagged_item in tagged_items_values]
+    ExtrasTaggedItem.objects.bulk_create(tagged_items)
+
+
+def delete_taggit_taggeditems(apps, schema_editor):
+    """
+    Delete all TaggedItem instances from taggit_taggeditem
+    """
+    TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
+    TaggitTaggedItem.objects.all().delete()
+
+
+def delete_taggit_tags(apps, schema_editor):
+    """
+    Delete all Tag instances from taggit_tag
+    """
+    TaggitTag = apps.get_model('taggit', 'Tag')
+    TaggitTag.objects.all().delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0019_tag_taggeditem'),
+        ('circuits', '0015_custom_tag_models'),
+        ('dcim', '0070_custom_tag_models'),
+        ('ipam', '0025_custom_tag_models'),
+        ('secrets', '0006_custom_tag_models'),
+        ('tenancy', '0006_custom_tag_models'),
+        ('virtualization', '0009_custom_tag_models'),
+    ]
+
+    operations = [
+        migrations.RunPython(copy_tags),
+        migrations.RunPython(copy_taggeditems),
+        migrations.RunPython(delete_taggit_taggeditems),
+        migrations.RunPython(delete_taggit_tags),
+    ]

+ 34 - 0
netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py

@@ -0,0 +1,34 @@
+# Generated by Django 2.1.4 on 2019-02-20 07:38
+
+from django.db import migrations, models
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0020_tag_data'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='tag',
+            name='color',
+            field=utilities.fields.ColorField(max_length=6, default='9e9e9e'),
+        ),
+        migrations.AddField(
+            model_name='tag',
+            name='comments',
+            field=models.TextField(blank=True, default=''),
+        ),
+        migrations.AddField(
+            model_name='tag',
+            name='created',
+            field=models.DateField(auto_now_add=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='tag',
+            name='last_updated',
+            field=models.DateTimeField(auto_now=True, null=True),
+        ),
+    ]

+ 48 - 0
netbox/extras/migrations/0022_custom_links.py

@@ -0,0 +1,48 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import extras.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0021_add_color_comments_changelog_to_tag'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CustomLink',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('text', models.CharField(max_length=500)),
+                ('url', models.CharField(max_length=500)),
+                ('weight', models.PositiveSmallIntegerField(default=100)),
+                ('group_name', models.CharField(blank=True, max_length=50)),
+                ('button_class', models.CharField(default='default', max_length=30)),
+                ('new_window', models.BooleanField()),
+                ('content_type', models.ForeignKey(limit_choices_to=extras.models.get_custom_link_models, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+            ],
+            options={
+                'ordering': ['group_name', 'weight', 'name'],
+            },
+        ),
+
+        # Update limit_choices_to for CustomFields, ExportTemplates, and Webhooks
+        migrations.AlterField(
+            model_name='customfield',
+            name='obj_type',
+            field=models.ManyToManyField(limit_choices_to=extras.models.get_custom_field_models, related_name='custom_fields', to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='exporttemplate',
+            name='content_type',
+            field=models.ForeignKey(limit_choices_to=extras.models.get_export_template_models, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='webhook',
+            name='obj_type',
+            field=models.ManyToManyField(limit_choices_to=extras.models.get_webhook_models, related_name='webhooks', to='contenttypes.ContentType'),
+        ),
+    ]

+ 109 - 5
netbox/extras/models.py

@@ -13,9 +13,11 @@ from django.template import Template, Context
 from django.urls import reverse
 from django.urls import reverse
 import graphviz
 import graphviz
 from jinja2 import Environment
 from jinja2 import Environment
+from taggit.models import TagBase, GenericTaggedItemBase
 
 
 from dcim.constants import CONNECTION_STATUS_CONNECTED
 from dcim.constants import CONNECTION_STATUS_CONNECTED
-from utilities.utils import deepmerge, foreground_color
+from utilities.fields import ColorField
+from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict
 from .constants import *
 from .constants import *
 from .querysets import ConfigContextQuerySet
 from .querysets import ConfigContextQuerySet
 
 
@@ -24,6 +26,10 @@ from .querysets import ConfigContextQuerySet
 # Webhooks
 # Webhooks
 #
 #
 
 
+def get_webhook_models():
+    return model_names_to_filter_dict(WEBHOOK_MODELS)
+
+
 class Webhook(models.Model):
 class Webhook(models.Model):
     """
     """
     A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
     A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
@@ -35,7 +41,7 @@ class Webhook(models.Model):
         to=ContentType,
         to=ContentType,
         related_name='webhooks',
         related_name='webhooks',
         verbose_name='Object types',
         verbose_name='Object types',
-        limit_choices_to={'model__in': WEBHOOK_MODELS},
+        limit_choices_to=get_webhook_models,
         help_text="The object(s) to which this Webhook applies."
         help_text="The object(s) to which this Webhook applies."
     )
     )
     name = models.CharField(
     name = models.CharField(
@@ -137,12 +143,16 @@ class CustomFieldModel(models.Model):
             return OrderedDict([(field, None) for field in fields])
             return OrderedDict([(field, None) for field in fields])
 
 
 
 
+def get_custom_field_models():
+    return model_names_to_filter_dict(CUSTOMFIELD_MODELS)
+
+
 class CustomField(models.Model):
 class CustomField(models.Model):
     obj_type = models.ManyToManyField(
     obj_type = models.ManyToManyField(
         to=ContentType,
         to=ContentType,
         related_name='custom_fields',
         related_name='custom_fields',
         verbose_name='Object(s)',
         verbose_name='Object(s)',
-        limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
+        limit_choices_to=get_custom_field_models,
         help_text='The object(s) to which this field applies.'
         help_text='The object(s) to which this field applies.'
     )
     )
     type = models.PositiveSmallIntegerField(
     type = models.PositiveSmallIntegerField(
@@ -303,6 +313,62 @@ class CustomFieldChoice(models.Model):
         CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
         CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
 
 
 
 
+#
+# Custom links
+#
+
+def get_custom_link_models():
+    return model_names_to_filter_dict(CUSTOMLINK_MODELS)
+
+
+class CustomLink(models.Model):
+    """
+    A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
+    code to be rendered with an object as context.
+    """
+    content_type = models.ForeignKey(
+        to=ContentType,
+        on_delete=models.CASCADE,
+        limit_choices_to=get_custom_link_models
+    )
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    text = models.CharField(
+        max_length=500,
+        help_text="Jinja2 template code for link text"
+    )
+    url = models.CharField(
+        max_length=500,
+        verbose_name='URL',
+        help_text="Jinja2 template code for link URL"
+    )
+    weight = models.PositiveSmallIntegerField(
+        default=100
+    )
+    group_name = models.CharField(
+        max_length=50,
+        blank=True,
+        help_text="Links with the same group will appear as a dropdown menu"
+    )
+    button_class = models.CharField(
+        max_length=30,
+        choices=BUTTON_CLASS_CHOICES,
+        default=BUTTON_CLASS_DEFAULT,
+        help_text="The class of the first link in a group will be used for the dropdown button"
+    )
+    new_window = models.BooleanField(
+        help_text="Force link to open in a new window"
+    )
+
+    class Meta:
+        ordering = ['group_name', 'weight', 'name']
+
+    def __str__(self):
+        return self.name
+
+
 #
 #
 # Graphs
 # Graphs
 #
 #
@@ -348,11 +414,15 @@ class Graph(models.Model):
 # Export templates
 # Export templates
 #
 #
 
 
+def get_export_template_models():
+    return model_names_to_filter_dict(EXPORTTEMPLATE_MODELS)
+
+
 class ExportTemplate(models.Model):
 class ExportTemplate(models.Model):
     content_type = models.ForeignKey(
     content_type = models.ForeignKey(
         to=ContentType,
         to=ContentType,
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
-        limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}
+        limit_choices_to=get_export_template_models
     )
     )
     name = models.CharField(
     name = models.CharField(
         max_length=100
         max_length=100
@@ -569,7 +639,7 @@ class TopologyMap(models.Model):
         from dcim.models import PowerPort
         from dcim.models import PowerPort
 
 
         # Add all power connections to the graph
         # Add all power connections to the graph
-        for pp in PowerPort.objects.filter(device__in=devices, connected_endpoint__device__in=devices):
+        for pp in PowerPort.objects.filter(device__in=devices, _connected_poweroutlet__device__in=devices):
             style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
             style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
             self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style)
             self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style)
 
 
@@ -890,3 +960,37 @@ class ObjectChange(models.Model):
             self.object_repr,
             self.object_repr,
             self.object_data,
             self.object_data,
         )
         )
+
+
+#
+# Tags
+#
+
+# TODO: figure out a way around this circular import for ObjectChange
+from utilities.models import ChangeLoggedModel  # noqa: E402
+
+
+class Tag(TagBase, ChangeLoggedModel):
+    color = ColorField(
+        default='9e9e9e'
+    )
+    comments = models.TextField(
+        blank=True,
+        default=''
+    )
+
+    def get_absolute_url(self):
+        return reverse('extras:tag', args=[self.slug])
+
+
+class TaggedItem(GenericTaggedItemBase):
+    tag = models.ForeignKey(
+        to=Tag,
+        related_name="%(app_label)s_%(class)s_items",
+        on_delete=models.CASCADE
+    )
+
+    class Meta:
+        index_together = (
+            ("content_type", "object_id")
+        )

+ 22 - 0
netbox/extras/signals.py

@@ -0,0 +1,22 @@
+from cacheops.signals import cache_invalidated, cache_read
+from prometheus_client import Counter
+
+
+cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits')
+cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses')
+cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations')
+
+
+def cache_read_collector(sender, func, hit, **kwargs):
+    if hit:
+        cacheops_cache_hit.inc()
+    else:
+        cacheops_cache_miss.inc()
+
+
+def cache_invalidated_collector(sender, obj_dict, **kwargs):
+    cacheops_cache_invalidated.inc()
+
+
+cache_read.connect(cache_read_collector)
+cache_invalidated.connect(cache_invalidated_collector)

+ 7 - 4
netbox/extras/tables.py

@@ -1,11 +1,13 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
-from taggit.models import Tag, TaggedItem
 
 
-from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
-from .models import ConfigContext, ObjectChange
+from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
+from .models import ConfigContext, ObjectChange, Tag, TaggedItem
 
 
 TAG_ACTIONS = """
 TAG_ACTIONS = """
+<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+    <i class="fa fa-history"></i>
+</a>
 {% if perms.taggit.change_tag %}
 {% if perms.taggit.change_tag %}
     <a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
     <a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
 {% endif %}
 {% endif %}
@@ -71,10 +73,11 @@ class TagTable(BaseTable):
         attrs={'td': {'class': 'text-right noprint'}},
         attrs={'td': {'class': 'text-right noprint'}},
         verbose_name=''
         verbose_name=''
     )
     )
+    color = ColorColumn()
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Tag
         model = Tag
-        fields = ('pk', 'name', 'items', 'slug', 'actions')
+        fields = ('pk', 'name', 'items', 'slug', 'color', 'actions')
 
 
 
 
 class TaggedItemTable(BaseTable):
 class TaggedItemTable(BaseTable):

+ 0 - 0
netbox/extras/templatetags/__init__.py


+ 68 - 0
netbox/extras/templatetags/custom_links.py

@@ -0,0 +1,68 @@
+from collections import OrderedDict
+
+from django import template
+from django.contrib.contenttypes.models import ContentType
+from django.utils.safestring import mark_safe
+from jinja2 import Environment
+
+from extras.models import CustomLink
+
+
+register = template.Library()
+
+LINK_BUTTON = '<a href="{}"{} class="btn btn-sm btn-{}">{}</a>\n'
+GROUP_BUTTON = '<div class="btn-group">\n' \
+               '<button type="button" class="btn btn-sm btn-{} dropdown-toggle" data-toggle="dropdown">\n' \
+               '{} <span class="caret"></span>\n' \
+               '</button>\n' \
+               '<ul class="dropdown-menu pull-right">\n'
+GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
+
+
+@register.simple_tag()
+def custom_links(obj):
+    """
+    Render all applicable links for the given object.
+    """
+    content_type = ContentType.objects.get_for_model(obj)
+    custom_links = CustomLink.objects.filter(content_type=content_type)
+    if not custom_links:
+        return ''
+
+    context = {
+        'obj': obj,
+    }
+    template_code = ''
+    group_names = OrderedDict()
+
+    # Organize custom links by group
+    for cl in custom_links:
+        if cl.group_name and cl.group_name in group_names:
+            group_names[cl.group_name].append(cl)
+        elif cl.group_name:
+            group_names[cl.group_name] = [cl]
+
+    # Add non-grouped links
+    for cl in custom_links:
+        if not cl.group_name:
+            link_target = ' target="_blank"' if cl.new_window else ''
+            template_code += LINK_BUTTON.format(
+                cl.url, link_target, cl.button_class, cl.text
+            )
+
+    # Add grouped links to template
+    for group, links in group_names.items():
+        template_code += GROUP_BUTTON.format(
+            links[0].button_class, group
+        )
+        for cl in links:
+            link_target = ' target="_blank"' if cl.new_window else ''
+            template_code += GROUP_LINK.format(
+                cl.url, link_target, cl.text
+            )
+        template_code += '</ul>\n</div>\n'
+
+    # Render template
+    rendered = Environment().from_string(source=template_code).render(**context)
+
+    return mark_safe(rendered)

+ 1 - 2
netbox/extras/tests/test_api.py

@@ -1,11 +1,10 @@
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
-from taggit.models import Tag
 
 
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
 from extras.constants import GRAPH_TYPE_SITE
 from extras.constants import GRAPH_TYPE_SITE
-from extras.models import ConfigContext, Graph, ExportTemplate
+from extras.models import ConfigContext, Graph, ExportTemplate, Tag
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
 
 

+ 32 - 1
netbox/extras/tests/test_customfields.py

@@ -6,9 +6,10 @@ from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
 from dcim.models import Site
 from dcim.models import Site
-from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL
+from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL, CF_TYPE_SELECT
 from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
 from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
+from virtualization.models import VirtualMachine
 
 
 
 
 class CustomFieldTest(TestCase):
 class CustomFieldTest(TestCase):
@@ -299,3 +300,33 @@ class CustomFieldAPITest(APITestCase):
         self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
         self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
         cfv = self.site.custom_field_values.get(field=self.cf_select)
         cfv = self.site.custom_field_values.get(field=self.cf_select)
         self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
         self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
+
+
+class CustomFieldChoiceAPITest(APITestCase):
+    def setUp(self):
+        super().setUp()
+
+        vm_content_type = ContentType.objects.get_for_model(VirtualMachine)
+
+        self.cf_1 = CustomField.objects.create(name="cf_1", type=CF_TYPE_SELECT)
+        self.cf_2 = CustomField.objects.create(name="cf_2", type=CF_TYPE_SELECT)
+
+        self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100)
+        self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50)
+        self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_2, value="cf_field_3", weight=10)
+
+    def test_list_cfc(self):
+        url = reverse('extras-api:custom-field-choice-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(len(response.data), 2)
+        self.assertEqual(len(response.data[self.cf_1.name]), 2)
+        self.assertEqual(len(response.data[self.cf_2.name]), 1)
+
+        self.assertTrue(self.cf_choice_1.value in response.data[self.cf_1.name])
+        self.assertTrue(self.cf_choice_2.value in response.data[self.cf_1.name])
+        self.assertTrue(self.cf_choice_3.value in response.data[self.cf_2.name])
+
+        self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
+        self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
+        self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])

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

@@ -4,17 +4,18 @@ import uuid
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.test import Client, TestCase
 from django.test import Client, TestCase
 from django.urls import reverse
 from django.urls import reverse
-from taggit.models import Tag
 
 
 from dcim.models import Site
 from dcim.models import Site
-from extras.models import ConfigContext, ObjectChange
+from extras.models import ConfigContext, ObjectChange, Tag
+from utilities.testing import create_test_user
 
 
 
 
 class TagTestCase(TestCase):
 class TagTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['extras.view_tag'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         Tag.objects.bulk_create([
         Tag.objects.bulk_create([
             Tag(name='Tag 1', slug='tag-1'),
             Tag(name='Tag 1', slug='tag-1'),
@@ -36,8 +37,9 @@ class TagTestCase(TestCase):
 class ConfigContextTestCase(TestCase):
 class ConfigContextTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['extras.view_configcontext'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -71,11 +73,9 @@ class ConfigContextTestCase(TestCase):
 class ObjectChangeTestCase(TestCase):
 class ObjectChangeTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['extras.view_objectchange'])
         self.client = Client()
         self.client = Client()
-
-        user = User(username='testuser', email='testuser@example.com')
-        user.save()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()

+ 3 - 0
netbox/extras/urls.py

@@ -1,6 +1,8 @@
 from django.urls import path
 from django.urls import path
 
 
 from extras import views
 from extras import views
+from extras.models import Tag
+
 
 
 app_name = 'extras'
 app_name = 'extras'
 urlpatterns = [
 urlpatterns = [
@@ -11,6 +13,7 @@ urlpatterns = [
     path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
     path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
     path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
     path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
     path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
     path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
+    path(r'tags/<slug: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/', views.ConfigContextListView.as_view(), name='configcontext_list'),

+ 21 - 14
netbox/extras/views.py

@@ -9,7 +9,6 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django.views.generic import View
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
-from taggit.models import Tag, TaggedItem
 
 
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
@@ -19,7 +18,7 @@ from .forms import (
     ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
     ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
     TagFilterForm, TagForm,
     TagFilterForm, TagForm,
 )
 )
-from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
+from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
 from .reports import get_report, get_reports
 from .reports import get_report, get_reports
 from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
 
 
@@ -28,9 +27,10 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
 # Tags
 # Tags
 #
 #
 
 
-class TagListView(ObjectListView):
+class TagListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'extras.view_tag'
     queryset = Tag.objects.annotate(
     queryset = Tag.objects.annotate(
-        items=Count('taggit_taggeditem_items', distinct=True)
+        items=Count('extras_taggeditem_items', distinct=True)
     ).order_by(
     ).order_by(
         'name'
         'name'
     )
     )
@@ -69,22 +69,23 @@ class TagView(View):
 
 
 
 
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
 class TagEditView(PermissionRequiredMixin, ObjectEditView):
-    permission_required = 'taggit.change_tag'
+    permission_required = 'extras.change_tag'
     model = Tag
     model = Tag
     model_form = TagForm
     model_form = TagForm
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
+    template_name = 'extras/tag_edit.html'
 
 
 
 
 class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
-    permission_required = 'taggit.delete_tag'
+    permission_required = 'extras.delete_tag'
     model = Tag
     model = Tag
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
 
 
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
-    permission_required = 'taggit.delete_tag'
+    permission_required = 'extras.delete_tag'
     queryset = Tag.objects.annotate(
     queryset = Tag.objects.annotate(
-        items=Count('taggit_taggeditem_items')
+        items=Count('extras_taggeditem_items')
     ).order_by(
     ).order_by(
         'name'
         'name'
     )
     )
@@ -96,7 +97,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Config contexts
 # Config contexts
 #
 #
 
 
-class ConfigContextListView(ObjectListView):
+class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'extras.view_configcontext'
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
     filter = filters.ConfigContextFilter
     filter = filters.ConfigContextFilter
     filter_form = ConfigContextFilterForm
     filter_form = ConfigContextFilterForm
@@ -104,7 +106,8 @@ class ConfigContextListView(ObjectListView):
     template_name = 'extras/configcontext_list.html'
     template_name = 'extras/configcontext_list.html'
 
 
 
 
-class ConfigContextView(View):
+class ConfigContextView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_configcontext'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -173,7 +176,8 @@ class ObjectConfigContextView(View):
 # Change logging
 # Change logging
 #
 #
 
 
-class ObjectChangeListView(ObjectListView):
+class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'extras.view_objectchange'
     queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
     queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
     filter = filters.ObjectChangeFilter
     filter = filters.ObjectChangeFilter
     filter_form = ObjectChangeFilterForm
     filter_form = ObjectChangeFilterForm
@@ -181,7 +185,8 @@ class ObjectChangeListView(ObjectListView):
     template_name = 'extras/objectchange_list.html'
     template_name = 'extras/objectchange_list.html'
 
 
 
 
-class ObjectChangeView(View):
+class ObjectChangeView(PermissionRequiredMixin, View):
+    permission_required = 'extras.view_objectchange'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -272,10 +277,11 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 # Reports
 # Reports
 #
 #
 
 
-class ReportListView(View):
+class ReportListView(PermissionRequiredMixin, View):
     """
     """
     Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
     Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
     """
     """
+    permission_required = 'extras.view_reportresult'
 
 
     def get(self, request):
     def get(self, request):
 
 
@@ -295,10 +301,11 @@ class ReportListView(View):
         })
         })
 
 
 
 
-class ReportView(View):
+class ReportView(PermissionRequiredMixin, View):
     """
     """
     Display a single Report and its associated ReportResult (if any).
     Display a single Report and its associated ReportResult (if any).
     """
     """
+    permission_required = 'extras.view_reportresult'
 
 
     def get(self, request, name):
     def get(self, request, name):
 
 

+ 1 - 1
netbox/extras/webhooks.py

@@ -14,7 +14,7 @@ def enqueue_webhooks(instance, user, request_id, action):
     Find Webhook(s) assigned to this instance + action and enqueue them
     Find Webhook(s) assigned to this instance + action and enqueue them
     to be processed
     to be processed
     """
     """
-    if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS:
+    if not settings.WEBHOOKS_ENABLED or instance._meta.label.lower() not in WEBHOOK_MODELS:
         return
         return
 
 
     # Retrieve any applicable Webhooks
     # Retrieve any applicable Webhooks

+ 9 - 4
netbox/ipam/api/nested_serializers.py

@@ -21,10 +21,11 @@ __all__ = [
 
 
 class NestedVRFSerializer(WritableNestedSerializer):
 class NestedVRFSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
+    prefix_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['id', 'url', 'name', 'rd']
+        fields = ['id', 'url', 'name', 'rd', 'prefix_count']
 
 
 
 
 #
 #
@@ -33,10 +34,11 @@ class NestedVRFSerializer(WritableNestedSerializer):
 
 
 class NestedRIRSerializer(WritableNestedSerializer):
 class NestedRIRSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
+    aggregate_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
 
 
 
 
 class NestedAggregateSerializer(WritableNestedSerializer):
 class NestedAggregateSerializer(WritableNestedSerializer):
@@ -53,18 +55,21 @@ class NestedAggregateSerializer(WritableNestedSerializer):
 
 
 class NestedRoleSerializer(WritableNestedSerializer):
 class NestedRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
+    prefix_count = serializers.IntegerField(read_only=True)
+    vlan_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count']
 
 
 
 
 class NestedVLANGroupSerializer(WritableNestedSerializer):
 class NestedVLANGroupSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
+    vlan_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'vlan_count']
 
 
 
 
 class NestedVLANSerializer(WritableNestedSerializer):
 class NestedVLANSerializer(WritableNestedSerializer):

+ 14 - 7
netbox/ipam/api/serializers.py

@@ -25,12 +25,14 @@ from .nested_serializers import *
 class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
+    ipaddress_count = serializers.IntegerField(read_only=True)
+    prefix_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
         fields = [
         fields = [
             'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
             'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
-            'created', 'last_updated',
+            'created', 'last_updated', 'ipaddress_count', 'prefix_count',
         ]
         ]
 
 
 
 
@@ -39,10 +41,11 @@ class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
 #
 #
 
 
 class RIRSerializer(ValidatedModelSerializer):
 class RIRSerializer(ValidatedModelSerializer):
+    aggregate_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = RIR
         model = RIR
-        fields = ['id', 'name', 'slug', 'is_private']
+        fields = ['id', 'name', 'slug', 'is_private', 'aggregate_count']
 
 
 
 
 class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
@@ -63,18 +66,21 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
 #
 #
 
 
 class RoleSerializer(ValidatedModelSerializer):
 class RoleSerializer(ValidatedModelSerializer):
+    prefix_count = serializers.IntegerField(read_only=True)
+    vlan_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
-        fields = ['id', 'name', 'slug', 'weight']
+        fields = ['id', 'name', 'slug', 'weight', 'prefix_count', 'vlan_count']
 
 
 
 
 class VLANGroupSerializer(ValidatedModelSerializer):
 class VLANGroupSerializer(ValidatedModelSerializer):
     site = NestedSiteSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True)
+    vlan_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
-        fields = ['id', 'name', 'slug', 'site']
+        fields = ['id', 'name', 'slug', 'site', 'vlan_count']
         validators = []
         validators = []
 
 
     def validate(self, data):
     def validate(self, data):
@@ -99,12 +105,13 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
     status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False)
     status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False)
     role = NestedRoleSerializer(required=False, allow_null=True)
     role = NestedRoleSerializer(required=False, allow_null=True)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
+    prefix_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
         fields = [
         fields = [
             'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name',
             'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name',
-            'custom_fields', 'created', 'last_updated',
+            'custom_fields', 'created', 'last_updated', 'prefix_count',
         ]
         ]
         validators = []
         validators = []
 
 
@@ -203,8 +210,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
-            'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
+            'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         read_only_fields = ['family']
         read_only_fields = ['family']
 
 

+ 28 - 6
netbox/ipam/api/views.py

@@ -1,4 +1,5 @@
 from django.conf import settings
 from django.conf import settings
+from django.db.models import Count
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from rest_framework import status
 from rest_framework import status
 from rest_framework.decorators import action
 from rest_framework.decorators import action
@@ -9,6 +10,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.utils import get_subquery
 from . import serializers
 from . import serializers
 
 
 
 
@@ -31,7 +33,10 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
 #
 #
 
 
 class VRFViewSet(CustomFieldModelViewSet):
 class VRFViewSet(CustomFieldModelViewSet):
-    queryset = VRF.objects.select_related('tenant').prefetch_related('tags')
+    queryset = VRF.objects.select_related('tenant').prefetch_related('tags').annotate(
+        ipaddress_count=get_subquery(IPAddress, 'vrf'),
+        prefix_count=get_subquery(Prefix, 'vrf')
+    )
     serializer_class = serializers.VRFSerializer
     serializer_class = serializers.VRFSerializer
     filterset_class = filters.VRFFilter
     filterset_class = filters.VRFFilter
 
 
@@ -41,7 +46,9 @@ class VRFViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class RIRViewSet(ModelViewSet):
 class RIRViewSet(ModelViewSet):
-    queryset = RIR.objects.all()
+    queryset = RIR.objects.annotate(
+        aggregate_count=Count('aggregates')
+    )
     serializer_class = serializers.RIRSerializer
     serializer_class = serializers.RIRSerializer
     filterset_class = filters.RIRFilter
     filterset_class = filters.RIRFilter
 
 
@@ -61,7 +68,10 @@ class AggregateViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class RoleViewSet(ModelViewSet):
 class RoleViewSet(ModelViewSet):
-    queryset = Role.objects.all()
+    queryset = Role.objects.annotate(
+        prefix_count=get_subquery(Prefix, 'role'),
+        vlan_count=get_subquery(VLAN, 'role')
+    )
     serializer_class = serializers.RoleSerializer
     serializer_class = serializers.RoleSerializer
     filterset_class = filters.RoleFilter
     filterset_class = filters.RoleFilter
 
 
@@ -71,7 +81,11 @@ class RoleViewSet(ModelViewSet):
 #
 #
 
 
 class PrefixViewSet(CustomFieldModelViewSet):
 class PrefixViewSet(CustomFieldModelViewSet):
-    queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role').prefetch_related('tags')
+    queryset = Prefix.objects.select_related(
+        'site', 'vrf__tenant', 'tenant', 'vlan', 'role'
+    ).prefetch_related(
+        'tags'
+    )
     serializer_class = serializers.PrefixSerializer
     serializer_class = serializers.PrefixSerializer
     filterset_class = filters.PrefixFilter
     filterset_class = filters.PrefixFilter
 
 
@@ -263,7 +277,9 @@ class IPAddressViewSet(CustomFieldModelViewSet):
 #
 #
 
 
 class VLANGroupViewSet(ModelViewSet):
 class VLANGroupViewSet(ModelViewSet):
-    queryset = VLANGroup.objects.select_related('site')
+    queryset = VLANGroup.objects.select_related('site').annotate(
+        vlan_count=Count('vlans')
+    )
     serializer_class = serializers.VLANGroupSerializer
     serializer_class = serializers.VLANGroupSerializer
     filterset_class = filters.VLANGroupFilter
     filterset_class = filters.VLANGroupFilter
 
 
@@ -273,7 +289,13 @@ class VLANGroupViewSet(ModelViewSet):
 #
 #
 
 
 class VLANViewSet(CustomFieldModelViewSet):
 class VLANViewSet(CustomFieldModelViewSet):
-    queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('tags')
+    queryset = VLAN.objects.select_related(
+        'site', 'group', 'tenant', 'role'
+    ).prefetch_related(
+        'tags'
+    ).annotate(
+        prefix_count=get_subquery(Prefix, 'role')
+    )
     serializer_class = serializers.VLANSerializer
     serializer_class = serializers.VLANSerializer
     filterset_class = filters.VLANFilter
     filterset_class = filters.VLANFilter
 
 

+ 18 - 4
netbox/ipam/filters.py

@@ -58,6 +58,10 @@ class AggregateFilter(CustomFieldFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    prefix = django_filters.CharFilter(
+        method='filter_prefix',
+        label='Prefix',
+    )
     rir_id = django_filters.ModelMultipleChoiceFilter(
     rir_id = django_filters.ModelMultipleChoiceFilter(
         queryset=RIR.objects.all(),
         queryset=RIR.objects.all(),
         label='RIR (ID)',
         label='RIR (ID)',
@@ -85,6 +89,15 @@ class AggregateFilter(CustomFieldFilterSet):
             pass
             pass
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
+    def filter_prefix(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        try:
+            query = str(netaddr.IPNetwork(value).cidr)
+            return queryset.filter(prefix=query)
+        except ValidationError:
+            return queryset.none()
+
 
 
 class RoleFilter(NameSlugSearchFilterSet):
 class RoleFilter(NameSlugSearchFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
@@ -94,7 +107,7 @@ class RoleFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = Role
         model = Role
-        fields = ['name', 'slug']
+        fields = ['id', 'name', 'slug']
 
 
 
 
 class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
 class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
@@ -307,12 +320,13 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
-        fields = ['family']
+        fields = ['family', 'dns_name']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():
             return queryset
             return queryset
         qs_filter = (
         qs_filter = (
+            Q(dns_name__icontains=value) |
             Q(description__icontains=value) |
             Q(description__icontains=value) |
             Q(address__istartswith=value)
             Q(address__istartswith=value)
         )
         )
@@ -367,7 +381,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = VLANGroup
         model = VLANGroup
-        fields = ['name', 'slug']
+        fields = ['id', 'name', 'slug']
 
 
 
 
 class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
 class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
@@ -459,7 +473,7 @@ class ServiceFilter(django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Service
         model = Service
-        fields = ['name', 'protocol', 'port']
+        fields = ['id', 'name', 'protocol', 'port']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 10 - 5
netbox/ipam/forms.py

@@ -645,8 +645,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
-            'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
+            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent',
+            'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
@@ -732,7 +732,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant',
+            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant',
         ]
         ]
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
@@ -905,13 +905,18 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
         required=False,
         required=False,
         widget=StaticSelect2()
         widget=StaticSelect2()
     )
     )
+    dns_name = forms.CharField(
+        max_length=255,
+        required=False
+    )
     description = forms.CharField(
     description = forms.CharField(
-        max_length=100, required=False
+        max_length=100,
+        required=False
     )
     )
 
 
     class Meta:
     class Meta:
         nullable_fields = [
         nullable_fields = [
-            'vrf', 'role', 'tenant', 'description',
+            'vrf', 'role', 'tenant', 'dns_name', 'description',
         ]
         ]
 
 
 
 

+ 45 - 0
netbox/ipam/migrations/0025_custom_tag_models.py

@@ -0,0 +1,45 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0024_vrf_allow_null_rd'),
+        ('extras', '0019_tag_taggeditem'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='aggregate',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='prefix',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='vlan',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+        migrations.AlterField(
+            model_name='vrf',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 18 - 0
netbox/ipam/migrations/0026_prefix_ordering_vrf_nulls_first.py

@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2019-04-20 00:57
+
+from django.db import migrations
+import django.db.models.expressions
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0025_custom_tag_models'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='prefix',
+            options={'ordering': [django.db.models.expressions.OrderBy(django.db.models.expressions.F('vrf'), nulls_first=True), 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
+        ),
+    ]

+ 19 - 0
netbox/ipam/migrations/0027_ipaddress_add_dns_name.py

@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2019-04-22 21:43
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0026_prefix_ordering_vrf_nulls_first'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ipaddress',
+            name='dns_name',
+            field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', regex='^[0-9A-Za-z.-]+$')]),
+        ),
+    ]

+ 32 - 14
netbox/ipam/models.py

@@ -4,18 +4,19 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError, ObjectDoesNotExist
 from django.core.exceptions import ValidationError, ObjectDoesNotExist
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
-from django.db.models import Q
+from django.db.models import F, Q
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.urls import reverse
 from django.urls import reverse
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from dcim.models import Interface
 from dcim.models import Interface
-from extras.models import CustomFieldModel, ObjectChange
+from extras.models import CustomFieldModel, ObjectChange, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
 from .constants import *
 from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .fields import IPNetworkField, IPAddressField
 from .querysets import PrefixQuerySet
 from .querysets import PrefixQuerySet
+from .validators import DNSValidator
 
 
 
 
 class VRF(ChangeLoggedModel, CustomFieldModel):
 class VRF(ChangeLoggedModel, CustomFieldModel):
@@ -56,7 +57,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
     csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
 
 
@@ -155,7 +156,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['prefix', 'rir', 'date_added', 'description']
     csv_headers = ['prefix', 'rir', 'date_added', 'description']
 
 
@@ -325,14 +326,14 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
     )
     )
 
 
     objects = PrefixQuerySet.as_manager()
     objects = PrefixQuerySet.as_manager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
         'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
     ]
     ]
 
 
     class Meta:
     class Meta:
-        ordering = ['vrf', 'family', 'prefix']
+        ordering = [F('vrf').asc(nulls_first=True), 'family', 'prefix']
         verbose_name_plural = 'prefixes'
         verbose_name_plural = 'prefixes'
 
 
     def __str__(self):
     def __str__(self):
@@ -367,11 +368,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
                     })
                     })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
-        if self.prefix:
+
+        if isinstance(self.prefix, netaddr.IPNetwork):
+
             # Clear host bits from prefix
             # Clear host bits from prefix
             self.prefix = self.prefix.cidr
             self.prefix = self.prefix.cidr
-            # Infer address family from IPNetwork object
+
+            # Record address family
             self.family = self.prefix.version
             self.family = self.prefix.version
+
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
     def to_csv(self):
     def to_csv(self):
@@ -573,6 +578,13 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         verbose_name='NAT (Inside)',
         verbose_name='NAT (Inside)',
         help_text='The IP for which this address is the "outside" IP'
         help_text='The IP for which this address is the "outside" IP'
     )
     )
+    dns_name = models.CharField(
+        max_length=255,
+        blank=True,
+        validators=[DNSValidator],
+        verbose_name='DNS Name',
+        help_text='Hostname or FQDN (not case-sensitive)'
+    )
     description = models.CharField(
     description = models.CharField(
         max_length=100,
         max_length=100,
         blank=True
         blank=True
@@ -584,11 +596,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
     )
     )
 
 
     objects = IPAddressManager()
     objects = IPAddressManager()
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
         'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
         'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
-        'description',
+        'dns_name', 'description',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -625,9 +637,14 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
                     })
                     })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
-        if self.address:
-            # Infer address family from IPAddress object
+
+        # Record address family
+        if isinstance(self.address, netaddr.IPNetwork):
             self.family = self.address.version
             self.family = self.address.version
+
+        # Force dns_name to lowercase
+        self.dns_name = self.dns_name.lower()
+
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
     def log_change(self, user, request_id, action):
     def log_change(self, user, request_id, action):
@@ -671,6 +688,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
             self.virtual_machine.name if self.virtual_machine else None,
             self.virtual_machine.name if self.virtual_machine else None,
             self.interface.name if self.interface else None,
             self.interface.name if self.interface else None,
             is_primary,
             is_primary,
+            self.dns_name,
             self.description,
             self.description,
         )
         )
 
 
@@ -812,7 +830,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
     csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
 
 
@@ -914,7 +932,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description']
     csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description']
 
 

+ 2 - 2
netbox/ipam/querysets.py

@@ -1,7 +1,7 @@
-from utilities.sql import NullsFirstQuerySet
+from django.db.models import QuerySet
 
 
 
 
-class PrefixQuerySet(NullsFirstQuerySet):
+class PrefixQuerySet(QuerySet):
 
 
     def annotate_depth(self, limit=None):
     def annotate_depth(self, limit=None):
         """
         """

+ 5 - 2
netbox/ipam/tables.py

@@ -340,7 +340,9 @@ class IPAddressTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
-        fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
+        fields = (
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
+        )
         row_attrs = {
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
         }
         }
@@ -354,7 +356,8 @@ class IPAddressDetailTable(IPAddressTable):
 
 
     class Meta(IPAddressTable.Meta):
     class Meta(IPAddressTable.Meta):
         fields = (
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name',
+            'description',
         )
         )
 
 
 
 

+ 4 - 4
netbox/ipam/tests/test_api.py

@@ -41,7 +41,7 @@ class VRFTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'rd', 'url']
+            ['id', 'name', 'prefix_count', 'rd', 'url']
         )
         )
 
 
     def test_create_vrf(self):
     def test_create_vrf(self):
@@ -149,7 +149,7 @@ class RIRTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['aggregate_count', 'id', 'name', 'slug', 'url']
         )
         )
 
 
     def test_create_rir(self):
     def test_create_rir(self):
@@ -353,7 +353,7 @@ class RoleTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
         )
         )
 
 
     def test_create_role(self):
     def test_create_role(self):
@@ -792,7 +792,7 @@ class VLANGroupTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['id', 'name', 'slug', 'url', 'vlan_count']
         )
         )
 
 
     def test_create_vlangroup(self):
     def test_create_vlangroup(self):

+ 19 - 15
netbox/ipam/tests/test_views.py

@@ -7,13 +7,15 @@ from django.urls import reverse
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from ipam.constants import IP_PROTOCOL_TCP
 from ipam.constants import IP_PROTOCOL_TCP
 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
 
 
 
 
 class VRFTestCase(TestCase):
 class VRFTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vrf'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         VRF.objects.bulk_create([
         VRF.objects.bulk_create([
             VRF(name='VRF 1', rd='65000:1'),
             VRF(name='VRF 1', rd='65000:1'),
@@ -41,8 +43,9 @@ class VRFTestCase(TestCase):
 class RIRTestCase(TestCase):
 class RIRTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_rir'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         RIR.objects.bulk_create([
         RIR.objects.bulk_create([
             RIR(name='RIR 1', slug='rir-1'),
             RIR(name='RIR 1', slug='rir-1'),
@@ -57,18 +60,13 @@ class RIRTestCase(TestCase):
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-    def test_rir(self):
-
-        rir = RIR.objects.first()
-        response = self.client.get(rir.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
-
 
 
 class AggregateTestCase(TestCase):
 class AggregateTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_aggregate'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         rir = RIR(name='RIR 1', slug='rir-1')
         rir = RIR(name='RIR 1', slug='rir-1')
         rir.save()
         rir.save()
@@ -99,8 +97,9 @@ class AggregateTestCase(TestCase):
 class RoleTestCase(TestCase):
 class RoleTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_role'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         Role.objects.bulk_create([
         Role.objects.bulk_create([
             Role(name='Role 1', slug='role-1'),
             Role(name='Role 1', slug='role-1'),
@@ -119,8 +118,9 @@ class RoleTestCase(TestCase):
 class PrefixTestCase(TestCase):
 class PrefixTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_prefix'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -151,8 +151,9 @@ class PrefixTestCase(TestCase):
 class IPAddressTestCase(TestCase):
 class IPAddressTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_ipaddress'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         vrf = VRF(name='VRF 1', rd='65000:1')
         vrf = VRF(name='VRF 1', rd='65000:1')
         vrf.save()
         vrf.save()
@@ -183,8 +184,9 @@ class IPAddressTestCase(TestCase):
 class VLANGroupTestCase(TestCase):
 class VLANGroupTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vlangroup'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()
@@ -209,8 +211,9 @@ class VLANGroupTestCase(TestCase):
 class VLANTestCase(TestCase):
 class VLANTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_vlan'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
         vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
         vlangroup.save()
         vlangroup.save()
@@ -241,8 +244,9 @@ class VLANTestCase(TestCase):
 class ServiceTestCase(TestCase):
 class ServiceTestCase(TestCase):
 
 
     def setUp(self):
     def setUp(self):
-
+        user = create_test_user(permissions=['ipam.view_service'])
         self.client = Client()
         self.client = Client()
+        self.client.force_login(user)
 
 
         site = Site(name='Site 1', slug='site-1')
         site = Site(name='Site 1', slug='site-1')
         site.save()
         site.save()

+ 8 - 0
netbox/ipam/validators.py

@@ -0,0 +1,8 @@
+from django.core.validators import RegexValidator
+
+
+DNSValidator = RegexValidator(
+    regex='^[0-9A-Za-z.-]+$',
+    message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names',
+    code='invalid'
+)

+ 39 - 19
netbox/ipam/views.py

@@ -113,7 +113,8 @@ def add_available_vlans(vlan_group, vlans):
 # VRFs
 # VRFs
 #
 #
 
 
-class VRFListView(ObjectListView):
+class VRFListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_vrf'
     queryset = VRF.objects.select_related('tenant')
     queryset = VRF.objects.select_related('tenant')
     filter = filters.VRFFilter
     filter = filters.VRFFilter
     filter_form = forms.VRFFilterForm
     filter_form = forms.VRFFilterForm
@@ -121,7 +122,8 @@ class VRFListView(ObjectListView):
     template_name = 'ipam/vrf_list.html'
     template_name = 'ipam/vrf_list.html'
 
 
 
 
-class VRFView(View):
+class VRFView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vrf'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -180,7 +182,8 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # RIRs
 # RIRs
 #
 #
 
 
-class RIRListView(ObjectListView):
+class RIRListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_rir'
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
     queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
     filter = filters.RIRFilter
     filter = filters.RIRFilter
     filter_form = forms.RIRFilterForm
     filter_form = forms.RIRFilterForm
@@ -286,7 +289,8 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Aggregates
 # Aggregates
 #
 #
 
 
-class AggregateListView(ObjectListView):
+class AggregateListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_aggregate'
     queryset = Aggregate.objects.select_related('rir').extra(select={
     queryset = Aggregate.objects.select_related('rir').extra(select={
         'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
         'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
     })
     })
@@ -312,7 +316,8 @@ class AggregateListView(ObjectListView):
         }
         }
 
 
 
 
-class AggregateView(View):
+class AggregateView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_aggregate'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -398,7 +403,8 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Prefix/VLAN roles
 # Prefix/VLAN roles
 #
 #
 
 
-class RoleListView(ObjectListView):
+class RoleListView(PermissionRequiredMixin, ObjectListView):
+    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'
     template_name = 'ipam/role_list.html'
@@ -433,7 +439,8 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Prefixes
 # Prefixes
 #
 #
 
 
-class PrefixListView(ObjectListView):
+class PrefixListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_prefix'
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
     filter = filters.PrefixFilter
     filter = filters.PrefixFilter
     filter_form = forms.PrefixFilterForm
     filter_form = forms.PrefixFilterForm
@@ -446,7 +453,8 @@ class PrefixListView(ObjectListView):
         return self.queryset.annotate_depth(limit=limit)
         return self.queryset.annotate_depth(limit=limit)
 
 
 
 
-class PrefixView(View):
+class PrefixView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_prefix'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -489,7 +497,8 @@ class PrefixView(View):
         })
         })
 
 
 
 
-class PrefixPrefixesView(View):
+class PrefixPrefixesView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_prefix'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -531,7 +540,8 @@ class PrefixPrefixesView(View):
         })
         })
 
 
 
 
-class PrefixIPAddressesView(View):
+class PrefixIPAddressesView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_prefix'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -617,7 +627,8 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # IP addresses
 # IP addresses
 #
 #
 
 
-class IPAddressListView(ObjectListView):
+class IPAddressListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_ipaddress'
     queryset = IPAddress.objects.select_related(
     queryset = IPAddress.objects.select_related(
         'vrf__tenant', 'tenant', 'nat_inside'
         'vrf__tenant', 'tenant', 'nat_inside'
     ).prefetch_related(
     ).prefetch_related(
@@ -629,7 +640,8 @@ class IPAddressListView(ObjectListView):
     template_name = 'ipam/ipaddress_list.html'
     template_name = 'ipam/ipaddress_list.html'
 
 
 
 
-class IPAddressView(View):
+class IPAddressView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_ipaddress'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -788,7 +800,8 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # VLAN groups
 # VLAN groups
 #
 #
 
 
-class VLANGroupListView(ObjectListView):
+class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_vlangroup'
     queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
     queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
     filter = filters.VLANGroupFilter
     filter = filters.VLANGroupFilter
     filter_form = forms.VLANGroupFilterForm
     filter_form = forms.VLANGroupFilterForm
@@ -822,7 +835,9 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_return_url = 'ipam:vlangroup_list'
     default_return_url = 'ipam:vlangroup_list'
 
 
 
 
-class VLANGroupVLANsView(View):
+class VLANGroupVLANsView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vlangroup'
+
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk)
         vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk)
@@ -861,7 +876,8 @@ class VLANGroupVLANsView(View):
 # VLANs
 # VLANs
 #
 #
 
 
-class VLANListView(ObjectListView):
+class VLANListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_vlan'
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
     queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
     filter = filters.VLANFilter
     filter = filters.VLANFilter
     filter_form = forms.VLANFilterForm
     filter_form = forms.VLANFilterForm
@@ -869,7 +885,8 @@ class VLANListView(ObjectListView):
     template_name = 'ipam/vlan_list.html'
     template_name = 'ipam/vlan_list.html'
 
 
 
 
-class VLANView(View):
+class VLANView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vlan'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -886,7 +903,8 @@ class VLANView(View):
         })
         })
 
 
 
 
-class VLANMembersView(View):
+class VLANMembersView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_vlan'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
@@ -954,7 +972,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 # Services
 # Services
 #
 #
 
 
-class ServiceListView(ObjectListView):
+class ServiceListView(PermissionRequiredMixin, ObjectListView):
+    permission_required = 'ipam.view_service'
     queryset = Service.objects.select_related('device', 'virtual_machine')
     queryset = Service.objects.select_related('device', 'virtual_machine')
     filter = filters.ServiceFilter
     filter = filters.ServiceFilter
     filter_form = forms.ServiceFilterForm
     filter_form = forms.ServiceFilterForm
@@ -962,7 +981,8 @@ class ServiceListView(ObjectListView):
     template_name = 'ipam/service_list.html'
     template_name = 'ipam/service_list.html'
 
 
 
 
-class ServiceView(View):
+class ServiceView(PermissionRequiredMixin, View):
+    permission_required = 'ipam.view_service'
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 

+ 0 - 3
netbox/netbox/admin.py

@@ -2,8 +2,6 @@ from django.conf import settings
 from django.contrib.admin import AdminSite
 from django.contrib.admin import AdminSite
 from django.contrib.auth.admin import GroupAdmin, UserAdmin
 from django.contrib.auth.admin import GroupAdmin, UserAdmin
 from django.contrib.auth.models import Group, User
 from django.contrib.auth.models import Group, User
-from taggit.admin import TagAdmin
-from taggit.models import Tag
 
 
 
 
 class NetBoxAdminSite(AdminSite):
 class NetBoxAdminSite(AdminSite):
@@ -20,7 +18,6 @@ admin_site = NetBoxAdminSite(name='admin')
 # Register external models
 # Register external models
 admin_site.register(Group, GroupAdmin)
 admin_site.register(Group, GroupAdmin)
 admin_site.register(User, UserAdmin)
 admin_site.register(User, UserAdmin)
-admin_site.register(Tag, TagAdmin)
 
 
 # Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
 # Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
 if settings.WEBHOOKS_ENABLED:
 if settings.WEBHOOKS_ENABLED:

+ 18 - 7
netbox/netbox/api.py

@@ -1,4 +1,5 @@
 from django.conf import settings
 from django.conf import settings
+from django.db.models import QuerySet
 from rest_framework import authentication, exceptions
 from rest_framework import authentication, exceptions
 from rest_framework.pagination import LimitOffsetPagination
 from rest_framework.pagination import LimitOffsetPagination
 from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
 from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
@@ -55,16 +56,31 @@ class TokenPermissions(DjangoModelPermissions):
     Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
     Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
     for unsafe requests (POST/PUT/PATCH/DELETE).
     for unsafe requests (POST/PUT/PATCH/DELETE).
     """
     """
+    # Override the stock perm_map to enforce view permissions
+    perms_map = {
+        'GET': ['%(app_label)s.view_%(model_name)s'],
+        'OPTIONS': [],
+        'HEAD': ['%(app_label)s.view_%(model_name)s'],
+        'POST': ['%(app_label)s.add_%(model_name)s'],
+        'PUT': ['%(app_label)s.change_%(model_name)s'],
+        'PATCH': ['%(app_label)s.change_%(model_name)s'],
+        'DELETE': ['%(app_label)s.delete_%(model_name)s'],
+    }
+
     def __init__(self):
     def __init__(self):
+
         # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
         # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
         self.authenticated_users_only = settings.LOGIN_REQUIRED
         self.authenticated_users_only = settings.LOGIN_REQUIRED
+
         super().__init__()
         super().__init__()
 
 
     def has_permission(self, request, view):
     def has_permission(self, request, view):
+
         # If token authentication is in use, verify that the token allows write operations (for unsafe methods).
         # If token authentication is in use, verify that the token allows write operations (for unsafe methods).
         if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
         if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
             if not request.auth.write_enabled:
             if not request.auth.write_enabled:
                 return False
                 return False
+
         return super().has_permission(request, view)
         return super().has_permission(request, view)
 
 
 
 
@@ -81,13 +97,8 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
 
 
     def paginate_queryset(self, queryset, request, view=None):
     def paginate_queryset(self, queryset, request, view=None):
 
 
-        if hasattr(queryset, 'all'):
-            # TODO: This breaks filtering by annotated values
-            # Make a clone of the queryset with any annotations stripped (performance hack)
-            qs = queryset.all()
-            qs.query.annotations.clear()
-            self.count = qs.count()
-
+        if isinstance(queryset, QuerySet):
+            self.count = queryset.count()
         else:
         else:
             # We're dealing with an iterable, not a QuerySet
             # We're dealing with an iterable, not a QuerySet
             self.count = len(queryset)
             self.count = len(queryset)

+ 26 - 11
netbox/netbox/configuration.example.py

@@ -25,6 +25,17 @@ DATABASE = {
 # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
 # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
 SECRET_KEY = ''
 SECRET_KEY = ''
 
 
+# Redis database settings. The Redis database is used for caching and background processing such as webhooks
+REDIS = {
+    'HOST': 'localhost',
+    'PORT': 6379,
+    'PASSWORD': '',
+    'DATABASE': 0,
+    'CACHE_DATABASE': 1,
+    'DEFAULT_TIMEOUT': 300,
+    'SSL': False,
+}
+
 
 
 #########################
 #########################
 #                       #
 #                       #
@@ -50,6 +61,9 @@ BANNER_LOGIN = ''
 # BASE_PATH = 'netbox/'
 # BASE_PATH = 'netbox/'
 BASE_PATH = ''
 BASE_PATH = ''
 
 
+# Cache timeout in seconds. Set to 0 to dissable caching. Defaults to 900 (15 minutes)
+CACHE_TIMEOUT = 900
+
 # Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
 # Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
 CHANGELOG_RETENTION = 90
 CHANGELOG_RETENTION = 90
 
 
@@ -58,7 +72,7 @@ CHANGELOG_RETENTION = 90
 # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers
 # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers
 CORS_ORIGIN_ALLOW_ALL = False
 CORS_ORIGIN_ALLOW_ALL = False
 CORS_ORIGIN_WHITELIST = [
 CORS_ORIGIN_WHITELIST = [
-    # 'hostname.example.com',
+    # 'https://hostname.example.com',
 ]
 ]
 CORS_ORIGIN_REGEX_WHITELIST = [
 CORS_ORIGIN_REGEX_WHITELIST = [
     # r'^(https?://)?(\w+\.)?example\.com$',
     # r'^(https?://)?(\w+\.)?example\.com$',
@@ -83,6 +97,14 @@ EMAIL = {
 # (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
 # (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
 ENFORCE_GLOBAL_UNIQUE = False
 ENFORCE_GLOBAL_UNIQUE = False
 
 
+# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
+# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
+EXEMPT_VIEW_PERMISSIONS = [
+    # 'dcim.site',
+    # 'dcim.region',
+    # 'ipam.prefix',
+]
+
 # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
 # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
 #   https://docs.djangoproject.com/en/1.11/topics/logging/
 #   https://docs.djangoproject.com/en/1.11/topics/logging/
 LOGGING = {}
 LOGGING = {}
@@ -107,6 +129,9 @@ MAX_PAGE_SIZE = 1000
 # the default value of this setting is derived from the installed location.
 # the default value of this setting is derived from the installed location.
 # MEDIA_ROOT = '/opt/netbox/netbox/media'
 # MEDIA_ROOT = '/opt/netbox/netbox/media'
 
 
+# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
+METRICS_ENABLED = False
+
 # Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM.
 # Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM.
 NAPALM_USERNAME = ''
 NAPALM_USERNAME = ''
 NAPALM_PASSWORD = ''
 NAPALM_PASSWORD = ''
@@ -125,16 +150,6 @@ PAGINATE_COUNT = 50
 # prefer IPv4 instead.
 # prefer IPv4 instead.
 PREFER_IPV4 = False
 PREFER_IPV4 = False
 
 
-# Redis database settings (optional). A Redis database is required only if the webhooks backend is enabled.
-REDIS = {
-    'HOST': 'localhost',
-    'PORT': 6379,
-    'PASSWORD': '',
-    'DATABASE': 0,
-    'DEFAULT_TIMEOUT': 300,
-    'SSL': False,
-}
-
 # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
 # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
 # this setting is derived from the installed location.
 # this setting is derived from the installed location.
 # REPORTS_ROOT = '/opt/netbox/netbox/reports'
 # REPORTS_ROOT = '/opt/netbox/netbox/reports'

+ 1 - 0
netbox/netbox/forms.py

@@ -16,6 +16,7 @@ OBJ_TYPE_CHOICES = (
         ('device', 'Devices'),
         ('device', 'Devices'),
         ('virtualchassis', 'Virtual Chassis'),
         ('virtualchassis', 'Virtual Chassis'),
         ('cable', 'Cables'),
         ('cable', 'Cables'),
+        ('powerfeed', 'Power Feeds'),
     )),
     )),
     ('IPAM', (
     ('IPAM', (
         ('vrf', 'VRFs'),
         ('vrf', 'VRFs'),

+ 252 - 97
netbox/netbox/settings.py

@@ -1,19 +1,37 @@
 import logging
 import logging
 import os
 import os
+import platform
 import socket
 import socket
-import sys
 import warnings
 import warnings
 
 
 from django.contrib.messages import constants as messages
 from django.contrib.messages import constants as messages
 from django.core.exceptions import ImproperlyConfigured
 from django.core.exceptions import ImproperlyConfigured
 
 
-# Django 2.1 requires Python 3.5+
-if sys.version_info < (3, 5):
+
+#
+# Environment setup
+#
+
+VERSION = '2.6.0'
+
+# Hostname
+HOSTNAME = platform.node()
+
+# Set the base directory two levels up
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# Django 2.1+ requires Python 3.5+
+if platform.python_version_tuple() < ('3', '5'):
     raise RuntimeError(
     raise RuntimeError(
-        "NetBox requires Python 3.5 or higher (current: Python {})".format(sys.version.split()[0])
+        "NetBox requires Python 3.5 or higher (current: Python {})".format(platform.python_version())
     )
     )
 
 
-# Check for configuration file
+
+#
+# Configuration import
+#
+
+# Import configuration parameters
 try:
 try:
     from netbox import configuration
     from netbox import configuration
 except ImportError:
 except ImportError:
@@ -21,22 +39,20 @@ except ImportError:
         "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
         "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
     )
     )
 
 
-
-VERSION = '2.5.13'
-
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
-# Import required configuration parameters
-ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
-for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
-    try:
-        globals()[setting] = getattr(configuration, setting)
-    except AttributeError:
+# Enforce required configuration parameters
+for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']:
+    if not hasattr(configuration, parameter):
         raise ImproperlyConfigured(
         raise ImproperlyConfigured(
-            "Mandatory setting {} is missing from configuration.py.".format(setting)
+            "Required parameter {} is missing from configuration.py.".format(parameter)
         )
         )
 
 
-# Import optional configuration parameters
+# Set required parameters
+ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS')
+DATABASE = getattr(configuration, 'DATABASE')
+REDIS = getattr(configuration, 'REDIS')
+SECRET_KEY = getattr(configuration, 'SECRET_KEY')
+
+# Set optional parameters
 ADMINS = getattr(configuration, 'ADMINS', [])
 ADMINS = getattr(configuration, 'ADMINS', [])
 BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
 BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
 BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
 BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
@@ -44,6 +60,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 if BASE_PATH:
 if BASE_PATH:
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
+CACHE_TIMEOUT = getattr(configuration, 'CACHE_TIMEOUT', 900)
 CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
 CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
@@ -51,22 +68,23 @@ 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)
-ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 EMAIL = getattr(configuration, 'EMAIL', {})
 EMAIL = getattr(configuration, 'EMAIL', {})
+ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
+EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGGING = getattr(configuration, 'LOGGING', {})
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
 MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
 MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
 MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
 MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
 MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
-NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
+METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
+NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
 NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
 NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
 NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
 NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
-NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
+NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
 PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
-REDIS = getattr(configuration, 'REDIS', {})
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
@@ -75,65 +93,54 @@ TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
 TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
 TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
 WEBHOOKS_ENABLED = getattr(configuration, 'WEBHOOKS_ENABLED', False)
 WEBHOOKS_ENABLED = getattr(configuration, 'WEBHOOKS_ENABLED', False)
 
 
-CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
-
-# Attempt to import LDAP configuration if it has been defined
-LDAP_IGNORE_CERT_ERRORS = False
-try:
-    from netbox.ldap_config import *
-    LDAP_CONFIGURED = True
-except ImportError:
-    LDAP_CONFIGURED = False
-
-# LDAP configuration (optional)
-if LDAP_CONFIGURED:
-    try:
-        import ldap
-        import django_auth_ldap
-        # Prepend LDAPBackend to the default ModelBackend
-        AUTHENTICATION_BACKENDS = [
-            'django_auth_ldap.backend.LDAPBackend',
-            'django.contrib.auth.backends.ModelBackend',
-        ]
-        # Optionally disable strict certificate checking
-        if LDAP_IGNORE_CERT_ERRORS:
-            ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
-        # Enable logging for django_auth_ldap
-        ldap_logger = logging.getLogger('django_auth_ldap')
-        ldap_logger.addHandler(logging.StreamHandler())
-        ldap_logger.setLevel(logging.DEBUG)
-    except ImportError:
-        raise ImproperlyConfigured(
-            "LDAP authentication has been configured, but django-auth-ldap is not installed. You can remove "
-            "netbox/ldap_config.py to disable LDAP."
-        )
 
 
+#
 # Database
 # Database
-configuration.DATABASE.update({'ENGINE': 'django.db.backends.postgresql'})
+#
+
+# Only PostgreSQL is supported
+if METRICS_ENABLED:
+    DATABASE.update({
+        'ENGINE': 'django_prometheus.db.backends.postgresql'
+    })
+else:
+    DATABASE.update({
+        'ENGINE': 'django.db.backends.postgresql'
+    })
+
 DATABASES = {
 DATABASES = {
-    'default': configuration.DATABASE,
+    'default': DATABASE,
 }
 }
 
 
-# Sessions
-if LOGIN_TIMEOUT is not None:
-    if type(LOGIN_TIMEOUT) is not int or LOGIN_TIMEOUT < 0:
-        raise ImproperlyConfigured(
-            "LOGIN_TIMEOUT must be a positive integer (value: {})".format(LOGIN_TIMEOUT)
-        )
-    # Django default is 1209600 seconds (14 days)
-    SESSION_COOKIE_AGE = LOGIN_TIMEOUT
-if SESSION_FILE_PATH is not None:
-    SESSION_ENGINE = 'django.contrib.sessions.backends.file'
 
 
+#
 # Redis
 # Redis
+#
+
 REDIS_HOST = REDIS.get('HOST', 'localhost')
 REDIS_HOST = REDIS.get('HOST', 'localhost')
 REDIS_PORT = REDIS.get('PORT', 6379)
 REDIS_PORT = REDIS.get('PORT', 6379)
 REDIS_PASSWORD = REDIS.get('PASSWORD', '')
 REDIS_PASSWORD = REDIS.get('PASSWORD', '')
 REDIS_DATABASE = REDIS.get('DATABASE', 0)
 REDIS_DATABASE = REDIS.get('DATABASE', 0)
+REDIS_CACHE_DATABASE = REDIS.get('CACHE_DATABASE', 1)
 REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300)
 REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300)
 REDIS_SSL = REDIS.get('SSL', False)
 REDIS_SSL = REDIS.get('SSL', False)
 
 
+
+#
+# Sessions
+#
+
+if LOGIN_TIMEOUT is not None:
+    # Django default is 1209600 seconds (14 days)
+    SESSION_COOKIE_AGE = LOGIN_TIMEOUT
+if SESSION_FILE_PATH is not None:
+    SESSION_ENGINE = 'django.contrib.sessions.backends.file'
+
+
+#
 # Email
 # Email
+#
+
 EMAIL_HOST = EMAIL.get('SERVER')
 EMAIL_HOST = EMAIL.get('SERVER')
 EMAIL_PORT = EMAIL.get('PORT', 25)
 EMAIL_PORT = EMAIL.get('PORT', 25)
 EMAIL_HOST_USER = EMAIL.get('USERNAME')
 EMAIL_HOST_USER = EMAIL.get('USERNAME')
@@ -142,7 +149,11 @@ EMAIL_TIMEOUT = EMAIL.get('TIMEOUT', 10)
 SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
 SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
 EMAIL_SUBJECT_PREFIX = '[NetBox] '
 EMAIL_SUBJECT_PREFIX = '[NetBox] '
 
 
-# Installed applications
+
+#
+# Django
+#
+
 INSTALLED_APPS = [
 INSTALLED_APPS = [
     'django.contrib.admin',
     'django.contrib.admin',
     'django.contrib.auth',
     'django.contrib.auth',
@@ -151,10 +162,12 @@ INSTALLED_APPS = [
     'django.contrib.messages',
     'django.contrib.messages',
     'django.contrib.staticfiles',
     'django.contrib.staticfiles',
     'django.contrib.humanize',
     'django.contrib.humanize',
+    'cacheops',
     'corsheaders',
     'corsheaders',
     'debug_toolbar',
     'debug_toolbar',
     'django_filters',
     'django_filters',
     'django_tables2',
     'django_tables2',
+    'django_prometheus',
     'mptt',
     'mptt',
     'rest_framework',
     'rest_framework',
     'taggit',
     'taggit',
@@ -179,6 +192,7 @@ if WEBHOOKS_ENABLED:
 # Middleware
 # Middleware
 MIDDLEWARE = (
 MIDDLEWARE = (
     'debug_toolbar.middleware.DebugToolbarMiddleware',
     'debug_toolbar.middleware.DebugToolbarMiddleware',
+    'django_prometheus.middleware.PrometheusBeforeMiddleware',
     'corsheaders.middleware.CorsMiddleware',
     'corsheaders.middleware.CorsMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
     'django.middleware.common.CommonMiddleware',
@@ -191,14 +205,16 @@ MIDDLEWARE = (
     'utilities.middleware.LoginRequiredMiddleware',
     'utilities.middleware.LoginRequiredMiddleware',
     'utilities.middleware.APIVersionMiddleware',
     'utilities.middleware.APIVersionMiddleware',
     'extras.middleware.ObjectChangeMiddleware',
     'extras.middleware.ObjectChangeMiddleware',
+    'django_prometheus.middleware.PrometheusAfterMiddleware',
 )
 )
 
 
 ROOT_URLCONF = 'netbox.urls'
 ROOT_URLCONF = 'netbox.urls'
 
 
+TEMPLATES_DIR = BASE_DIR + '/templates'
 TEMPLATES = [
 TEMPLATES = [
     {
     {
         'BACKEND': 'django.template.backends.django.DjangoTemplates',
         'BACKEND': 'django.template.backends.django.DjangoTemplates',
-        'DIRS': [BASE_DIR + '/templates'],
+        'DIRS': [TEMPLATES_DIR],
         'APP_DIRS': True,
         'APP_DIRS': True,
         'OPTIONS': {
         'OPTIONS': {
             'context_processors': [
             'context_processors': [
@@ -213,16 +229,21 @@ TEMPLATES = [
     },
     },
 ]
 ]
 
 
-# WSGI
-WSGI_APPLICATION = 'netbox.wsgi.application'
-SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
-USE_X_FORWARDED_HOST = True
+# Authentication
+AUTHENTICATION_BACKENDS = [
+    'utilities.auth_backends.ViewExemptModelBackend',
+]
 
 
 # Internationalization
 # Internationalization
 LANGUAGE_CODE = 'en-us'
 LANGUAGE_CODE = 'en-us'
 USE_I18N = True
 USE_I18N = True
 USE_TZ = True
 USE_TZ = True
 
 
+# WSGI
+WSGI_APPLICATION = 'netbox.wsgi.application'
+SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
+USE_X_FORWARDED_HOST = True
+
 # Static files (CSS, JavaScript, Images)
 # Static files (CSS, JavaScript, Images)
 STATIC_ROOT = BASE_DIR + '/static'
 STATIC_ROOT = BASE_DIR + '/static'
 STATIC_URL = '/{}static/'.format(BASE_PATH)
 STATIC_URL = '/{}static/'.format(BASE_PATH)
@@ -244,22 +265,134 @@ MESSAGE_TAGS = {
 # Authentication URLs
 # Authentication URLs
 LOGIN_URL = '/{}login/'.format(BASE_PATH)
 LOGIN_URL = '/{}login/'.format(BASE_PATH)
 
 
-# Secrets
-SECRETS_MIN_PUBKEY_SIZE = 2048
+CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
 
 
-# Pagination
-PER_PAGE_DEFAULTS = [
-    25, 50, 100, 250, 500, 1000
-]
-if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
-    PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
-    PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)
 
 
+#
+# LDAP authentication (optional)
+#
+
+try:
+    from netbox import ldap_config as LDAP_CONFIG
+except ImportError:
+    LDAP_CONFIG = None
+
+if LDAP_CONFIG is not None:
+
+    # Check that django_auth_ldap is installed
+    try:
+        import ldap
+        import django_auth_ldap
+    except ImportError:
+        raise ImproperlyConfigured(
+            "LDAP authentication has been configured, but django-auth-ldap is not installed. Remove "
+            "netbox/ldap_config.py to disable LDAP."
+        )
+
+    # Required configuration parameters
+    try:
+        AUTH_LDAP_SERVER_URI = getattr(LDAP_CONFIG, 'AUTH_LDAP_SERVER_URI')
+    except AttributeError:
+        raise ImproperlyConfigured(
+            "Required parameter AUTH_LDAP_SERVER_URI is missing from ldap_config.py."
+        )
+
+    # Optional configuration parameters
+    AUTH_LDAP_ALWAYS_UPDATE_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_ALWAYS_UPDATE_USER', True)
+    AUTH_LDAP_AUTHORIZE_ALL_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_AUTHORIZE_ALL_USERS', False)
+    AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', False)
+    AUTH_LDAP_BIND_DN = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_DN', '')
+    AUTH_LDAP_BIND_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_PASSWORD', '')
+    AUTH_LDAP_CACHE_TIMEOUT = getattr(LDAP_CONFIG, 'AUTH_LDAP_CACHE_TIMEOUT', 0)
+    AUTH_LDAP_CONNECTION_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_CONNECTION_OPTIONS', {})
+    AUTH_LDAP_DENY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_DENY_GROUP', None)
+    AUTH_LDAP_FIND_GROUP_PERMS = getattr(LDAP_CONFIG, 'AUTH_LDAP_FIND_GROUP_PERMS', False)
+    AUTH_LDAP_GLOBAL_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_GLOBAL_OPTIONS', {})
+    AUTH_LDAP_GROUP_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_SEARCH', None)
+    AUTH_LDAP_GROUP_TYPE = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_TYPE', None)
+    AUTH_LDAP_MIRROR_GROUPS = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS', None)
+    AUTH_LDAP_MIRROR_GROUPS_EXCEPT = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS_EXCEPT', None)
+    AUTH_LDAP_PERMIT_EMPTY_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_PERMIT_EMPTY_PASSWORD', False)
+    AUTH_LDAP_REQUIRE_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_REQUIRE_GROUP', None)
+    AUTH_LDAP_NO_NEW_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_NO_NEW_USERS', False)
+    AUTH_LDAP_START_TLS = getattr(LDAP_CONFIG, 'AUTH_LDAP_START_TLS', False)
+    AUTH_LDAP_USER_QUERY_FIELD = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_QUERY_FIELD', None)
+    AUTH_LDAP_USER_ATTRLIST = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTRLIST', None)
+    AUTH_LDAP_USER_ATTR_MAP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTR_MAP', {})
+    AUTH_LDAP_USER_DN_TEMPLATE = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_DN_TEMPLATE', None)
+    AUTH_LDAP_USER_FLAGS_BY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {})
+    AUTH_LDAP_USER_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_SEARCH', None)
+
+    # Optionally disable strict certificate checking
+    if getattr(LDAP_CONFIG, 'LDAP_IGNORE_CERT_ERRORS', False):
+        ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
+
+    # Prepend LDAPBackend to the authentication backends list
+    AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend')
+
+    # Enable logging for django_auth_ldap
+    ldap_logger = logging.getLogger('django_auth_ldap')
+    ldap_logger.addHandler(logging.StreamHandler())
+    ldap_logger.setLevel(logging.DEBUG)
+
+
+#
+# Caching
+#
+
+if REDIS_SSL:
+    REDIS_CACHE_CON_STRING = 'rediss://'
+else:
+    REDIS_CACHE_CON_STRING = 'redis://'
+
+if REDIS_PASSWORD:
+    REDIS_CACHE_CON_STRING = '{}{}@'.format(REDIS_CACHE_CON_STRING, REDIS_PASSWORD)
+
+REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(REDIS_CACHE_CON_STRING, REDIS_HOST, REDIS_PORT, REDIS_CACHE_DATABASE)
+
+if not CACHE_TIMEOUT:
+    CACHEOPS_ENABLED = False
+else:
+    CACHEOPS_ENABLED = True
+
+CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
+CACHEOPS_DEFAULTS = {
+    'timeout': CACHE_TIMEOUT
+}
+CACHEOPS = {
+    'auth.user': {'ops': 'get', 'timeout': 60 * 15},
+    'auth.*': {'ops': ('fetch', 'get')},
+    'auth.permission': {'ops': 'all'},
+    'dcim.*': {'ops': 'all'},
+    'ipam.*': {'ops': 'all'},
+    'extras.*': {'ops': 'all'},
+    'secrets.*': {'ops': 'all'},
+    'users.*': {'ops': 'all'},
+    'tenancy.*': {'ops': 'all'},
+    'virtualization.*': {'ops': 'all'},
+}
+CACHEOPS_DEGRADE_ON_FAILURE = True
+
+
+#
+# Django Prometheus
+#
+
+PROMETHEUS_EXPORT_MIGRATIONS = False
+
+
+#
 # Django filters
 # Django filters
+#
+
 FILTERS_NULL_CHOICE_LABEL = 'None'
 FILTERS_NULL_CHOICE_LABEL = 'None'
 FILTERS_NULL_CHOICE_VALUE = 'null'
 FILTERS_NULL_CHOICE_VALUE = 'null'
 
 
+
+#
 # Django REST framework (API)
 # Django REST framework (API)
+#
+
 REST_FRAMEWORK_VERSION = VERSION[0:3]  # Use major.minor as API version
 REST_FRAMEWORK_VERSION = VERSION[0:3]  # Use major.minor as API version
 REST_FRAMEWORK = {
 REST_FRAMEWORK = {
     'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
     'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
@@ -284,19 +417,11 @@ REST_FRAMEWORK = {
     'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
     'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
 }
 }
 
 
-# Django RQ (Webhooks backend)
-RQ_QUEUES = {
-    'default': {
-        'HOST': REDIS_HOST,
-        'PORT': REDIS_PORT,
-        'DB': REDIS_DATABASE,
-        'PASSWORD': REDIS_PASSWORD,
-        'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
-        'SSL': REDIS_SSL,
-    }
-}
 
 
-# drf_yasg settings for Swagger
+#
+# drf_yasg (OpenAPI/Swagger)
+#
+
 SWAGGER_SETTINGS = {
 SWAGGER_SETTINGS = {
     'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
     'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
     'DEFAULT_FIELD_INSPECTORS': [
     'DEFAULT_FIELD_INSPECTORS': [
@@ -310,6 +435,7 @@ SWAGGER_SETTINGS = {
         'drf_yasg.inspectors.ChoiceFieldInspector',
         'drf_yasg.inspectors.ChoiceFieldInspector',
         'drf_yasg.inspectors.FileFieldInspector',
         'drf_yasg.inspectors.FileFieldInspector',
         'drf_yasg.inspectors.DictFieldInspector',
         'drf_yasg.inspectors.DictFieldInspector',
+        'drf_yasg.inspectors.SerializerMethodFieldInspector',
         'drf_yasg.inspectors.SimpleFieldInspector',
         'drf_yasg.inspectors.SimpleFieldInspector',
         'drf_yasg.inspectors.StringDefaultFieldInspector',
         'drf_yasg.inspectors.StringDefaultFieldInspector',
     ],
     ],
@@ -334,14 +460,43 @@ SWAGGER_SETTINGS = {
 }
 }
 
 
 
 
+#
+# Django RQ (Webhooks backend)
+#
+
+RQ_QUEUES = {
+    'default': {
+        'HOST': REDIS_HOST,
+        'PORT': REDIS_PORT,
+        'DB': REDIS_DATABASE,
+        'PASSWORD': REDIS_PASSWORD,
+        'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
+        'SSL': REDIS_SSL,
+    }
+}
+
+
+#
 # Django debug toolbar
 # Django debug toolbar
+#
+
 INTERNAL_IPS = (
 INTERNAL_IPS = (
     '127.0.0.1',
     '127.0.0.1',
     '::1',
     '::1',
 )
 )
 
 
 
 
-try:
-    HOSTNAME = socket.gethostname()
-except Exception:
-    HOSTNAME = 'localhost'
+#
+# NetBox internal settings
+#
+
+# Secrets
+SECRETS_MIN_PUBKEY_SIZE = 2048
+
+# Pagination
+PER_PAGE_DEFAULTS = [
+    25, 50, 100, 250, 500, 1000
+]
+if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
+    PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
+    PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)

+ 5 - 0
netbox/netbox/urls.py

@@ -74,6 +74,11 @@ if settings.DEBUG:
         path(r'__debug__/', include(debug_toolbar.urls)),
         path(r'__debug__/', include(debug_toolbar.urls)),
     ]
     ]
 
 
+if settings.METRICS_ENABLED:
+    _patterns += [
+        path('', include('django_prometheus.urls')),
+    ]
+
 # Prepend BASE_PATH
 # Prepend BASE_PATH
 urlpatterns = [
 urlpatterns = [
     path(r'{}'.format(settings.BASE_PATH), include(_patterns))
     path(r'{}'.format(settings.BASE_PATH), include(_patterns))

+ 14 - 4
netbox/netbox/views.py

@@ -11,13 +11,15 @@ from circuits.filters import CircuitFilter, ProviderFilter
 from circuits.models import Circuit, Provider
 from circuits.models import Circuit, Provider
 from circuits.tables import CircuitTable, ProviderTable
 from circuits.tables import CircuitTable, ProviderTable
 from dcim.filters import (
 from dcim.filters import (
-    CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
+    CableFilter, DeviceFilter, DeviceTypeFilter, PowerFeedFilter, RackFilter, RackGroupFilter, SiteFilter,
+    VirtualChassisFilter,
 )
 )
 from dcim.models import (
 from dcim.models import (
-    Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
+    Cable, ConsolePort, Device, DeviceType, Interface, PowerFeed, PowerPort, Rack, RackGroup, Site, VirtualChassis
 )
 )
 from dcim.tables import (
 from dcim.tables import (
-    CableTable, DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
+    CableTable, DeviceDetailTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
+    VirtualChassisTable,
 )
 )
 from extras.models import ObjectChange, ReportResult, TopologyMap
 from extras.models import ObjectChange, ReportResult, TopologyMap
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
 from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
@@ -94,6 +96,12 @@ SEARCH_TYPES = OrderedDict((
         'table': CableTable,
         'table': CableTable,
         'url': 'dcim:cable_list',
         'url': 'dcim:cable_list',
     }),
     }),
+    ('powerfeed', {
+        'queryset': PowerFeed.objects.all(),
+        'filter': PowerFeedFilter,
+        'table': PowerFeedTable,
+        'url': 'dcim:powerfeed_list',
+    }),
     # IPAM
     # IPAM
     ('vrf', {
     ('vrf', {
         'queryset': VRF.objects.select_related('tenant'),
         'queryset': VRF.objects.select_related('tenant'),
@@ -166,7 +174,7 @@ class HomeView(View):
             connected_endpoint__isnull=False
             connected_endpoint__isnull=False
         )
         )
         connected_powerports = PowerPort.objects.filter(
         connected_powerports = PowerPort.objects.filter(
-            connected_endpoint__isnull=False
+            _connected_poweroutlet__isnull=False
         )
         )
         connected_interfaces = Interface.objects.filter(
         connected_interfaces = Interface.objects.filter(
             _connected_interface__isnull=False,
             _connected_interface__isnull=False,
@@ -182,11 +190,13 @@ class HomeView(View):
 
 
             # DCIM
             # DCIM
             'rack_count': Rack.objects.count(),
             'rack_count': Rack.objects.count(),
+            'devicetype_count': DeviceType.objects.count(),
             'device_count': Device.objects.count(),
             'device_count': Device.objects.count(),
             'interface_connections_count': connected_interfaces.count(),
             'interface_connections_count': connected_interfaces.count(),
             'cable_count': cables.count(),
             'cable_count': cables.count(),
             'console_connections_count': connected_consoleports.count(),
             'console_connections_count': connected_consoleports.count(),
             'power_connections_count': connected_powerports.count(),
             'power_connections_count': connected_powerports.count(),
+            'powerfeed_count': PowerFeed.objects.count(),
 
 
             # IPAM
             # IPAM
             'vrf_count': VRF.objects.count(),
             'vrf_count': VRF.objects.count(),

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

@@ -586,6 +586,7 @@ ul.nav-tabs, ul.nav-pills {
 /* Fix progress bar margin inside table cells */
 /* Fix progress bar margin inside table cells */
 td .progress {
 td .progress {
     margin-bottom: 0;
     margin-bottom: 0;
+    min-width: 100px;
 }
 }
 textarea {
 textarea {
     font-family: Consolas, Lucida Console, monospace;
     font-family: Consolas, Lucida Console, monospace;

+ 2 - 1
netbox/secrets/api/nested_serializers.py

@@ -10,7 +10,8 @@ __all__ = [
 
 
 class NestedSecretRoleSerializer(WritableNestedSerializer):
 class NestedSecretRoleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
     url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
+    secret_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole
-        fields = ['id', 'url', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug', 'secret_count']

+ 2 - 1
netbox/secrets/api/serializers.py

@@ -14,10 +14,11 @@ from .nested_serializers import *
 #
 #
 
 
 class SecretRoleSerializer(ValidatedModelSerializer):
 class SecretRoleSerializer(ValidatedModelSerializer):
+    secret_count = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'name', 'slug', 'secret_count']
 
 
 
 
 class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer):

+ 4 - 1
netbox/secrets/api/views.py

@@ -1,6 +1,7 @@
 import base64
 import base64
 
 
 from Crypto.PublicKey import RSA
 from Crypto.PublicKey import RSA
+from django.db.models import Count
 from django.http import HttpResponseBadRequest
 from django.http import HttpResponseBadRequest
 from rest_framework.exceptions import ValidationError
 from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.permissions import IsAuthenticated
@@ -32,7 +33,9 @@ class SecretsFieldChoicesViewSet(FieldChoicesViewSet):
 #
 #
 
 
 class SecretRoleViewSet(ModelViewSet):
 class SecretRoleViewSet(ModelViewSet):
-    queryset = SecretRole.objects.all()
+    queryset = SecretRole.objects.annotate(
+        secret_count=Count('secrets')
+    )
     serializer_class = serializers.SecretRoleSerializer
     serializer_class = serializers.SecretRoleSerializer
     permission_classes = [IsAuthenticated]
     permission_classes = [IsAuthenticated]
     filterset_class = filters.SecretRoleFilter
     filterset_class = filters.SecretRoleFilter

+ 2 - 2
netbox/secrets/filters.py

@@ -11,10 +11,10 @@ class SecretRoleFilter(NameSlugSearchFilterSet):
 
 
     class Meta:
     class Meta:
         model = SecretRole
         model = SecretRole
-        fields = ['name', 'slug']
+        fields = ['id', 'name', 'slug']
 
 
 
 
-class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet):
+class SecretFilter(CustomFieldFilterSet):
     id__in = NumericInFilter(
     id__in = NumericInFilter(
         field_name='id',
         field_name='id',
         lookup_expr='in'
         lookup_expr='in'

+ 20 - 0
netbox/secrets/migrations/0006_custom_tag_models.py

@@ -0,0 +1,20 @@
+# Generated by Django 2.1.4 on 2019-02-20 06:56
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('secrets', '0005_change_logging'),
+        ('extras', '0019_tag_taggeditem'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='secret',
+            name='tags',
+            field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+        ),
+    ]

+ 2 - 2
netbox/secrets/models.py

@@ -14,7 +14,7 @@ from django.urls import reverse
 from django.utils.encoding import force_bytes
 from django.utils.encoding import force_bytes
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from extras.models import CustomFieldModel
+from extras.models import CustomFieldModel, TaggedItem
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from .exceptions import InvalidKey
 from .exceptions import InvalidKey
 from .hashers import SecretValidationHasher
 from .hashers import SecretValidationHasher
@@ -345,7 +345,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
         object_id_field='obj_id'
         object_id_field='obj_id'
     )
     )
 
 
-    tags = TaggableManager()
+    tags = TaggableManager(through=TaggedItem)
 
 
     plaintext = None
     plaintext = None
     csv_headers = ['device', 'role', 'name', 'plaintext']
     csv_headers = ['device', 'role', 'name', 'plaintext']

+ 1 - 1
netbox/secrets/tests/test_api.py

@@ -78,7 +78,7 @@ class SecretRoleTest(APITestCase):
 
 
         self.assertEqual(
         self.assertEqual(
             sorted(response.data['results'][0]),
             sorted(response.data['results'][0]),
-            ['id', 'name', 'slug', 'url']
+            ['id', 'name', 'secret_count', 'slug', 'url']
         )
         )
 
 
     def test_create_secretrole(self):
     def test_create_secretrole(self):

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