diff --git a/.travis.yml b/.travis.yml index 13c6d406b..29fa87b64 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ sudo: required services: - postgresql + - redis-server addons: postgresql: "9.4" language: python diff --git a/CHANGELOG.md b/CHANGELOG.md index c1827f8af..013884673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,218 @@ -2.5.14 (FUTURE) +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 ## Bug Fixes +* [#2968](https://github.com/digitalocean/netbox/issues/2968) - Correct API documentation for SerializerMethodFields * [#3231](https://github.com/digitalocean/netbox/issues/3231) - Fixed cosmetic error indicating a missing schema migration +## 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) diff --git a/base_requirements.txt b/base_requirements.txt index 3d1578400..f0f6cfe38 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -2,6 +2,10 @@ # https://github.com/django/django Django +# Django caching using Redis +# https://github.com/Suor/django-cacheops +django-cacheops + # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers django-cors-headers @@ -18,6 +22,14 @@ django-filter # https://github.com/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 # https://github.com/jieter/django-tables2 django-tables2 diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md new file mode 100644 index 000000000..32ac77cbf --- /dev/null +++ b/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 +``` diff --git a/docs/additional-features/prometheus-metrics.md b/docs/additional-features/prometheus-metrics.md new file mode 100644 index 000000000..0aa944b74 --- /dev/null +++ b/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 +``` diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 68f342e88..0e74640fa 100644 --- a/docs/additional-features/webhooks.md +++ b/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. -## 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 The webhook POST request is structured as so (assuming `application/json` as the Content-Type): diff --git a/docs/api/overview.md b/docs/api/overview.md index 00ff9c27e..e74a12371 100644 --- a/docs/api/overview.md +++ b/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 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 ``` -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 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: diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index f8bd70e88..4ebb56290 100644 --- a/docs/configuration/optional-settings.md +++ b/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 Default: 90 @@ -64,7 +72,13 @@ If True, cross-origin resource sharing (CORS) requests will be accepted from all ## 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 `.`. 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 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_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. 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. diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index fb08e643b..9c3ea4d4f 100644 --- a/docs/configuration/required-settings.md +++ b/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. `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. diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 176a70676..51486f7c1 100644 --- a/docs/core-functionality/devices.md +++ b/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*. -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. diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 5e1f92fcc..f841fc50a 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -29,6 +29,7 @@ Update the following static libraries to their most recent stable release: * Bootstrap 3 * Font Awesome 4 +* Select2 * jQuery * jQuery UI diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md index 8941a4949..bd08c4f02 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/2-netbox.md @@ -1,18 +1,18 @@ # 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** ```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** ```no-highlight # 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 # 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 ``` -## 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 Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. diff --git a/docs/installation/migrating-to-python3.md b/docs/installation/migrating-to-python3.md index 1d5ceb977..2c2c2b917 100644 --- a/docs/installation/migrating-to-python3.md +++ b/docs/installation/migrating-to-python3.md @@ -1,7 +1,7 @@ # Migration !!! 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 @@ -36,9 +36,3 @@ If using LDAP authentication, install the `django-auth-ldap` package: ```no-highlight # pip3 install django-auth-ldap ``` - -If using Webhooks, install the `django-rq` package: - -```no-highlight -# pip3 install django-rq -``` diff --git a/mkdocs.yml b/mkdocs.yml index a0185e56e..bf2d4c0b3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,6 +36,8 @@ pages: - Reports: 'additional-features/reports.md' - Webhooks: 'additional-features/webhooks.md' - Change Logging: 'additional-features/change-logging.md' + - Caching: 'additional-features/caching.md' + - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 211dc4007..067b82282 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -17,10 +17,11 @@ __all__ = [ class NestedProviderSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'circuit_count'] # @@ -29,10 +30,11 @@ class NestedProviderSerializer(WritableNestedSerializer): class NestedCircuitTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') + circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'circuit_count'] class NestedCircuitSerializer(WritableNestedSerializer): diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index e94875c21..39a0b6b26 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,3 +1,4 @@ +from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.constants import CIRCUIT_STATUS_CHOICES @@ -16,12 +17,13 @@ from .nested_serializers import * class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): tags = TagListSerializerField(required=False) + circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider fields = [ '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): + circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'circuit_count'] class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 877d85f85..ad48174e6 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,3 +1,4 @@ +from django.db.models import Count from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response @@ -27,7 +28,9 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): # class ProviderViewSet(CustomFieldModelViewSet): - queryset = Provider.objects.prefetch_related('tags') + queryset = Provider.objects.prefetch_related('tags').annotate( + circuit_count=Count('circuits') + ) serializer_class = serializers.ProviderSerializer filterset_class = filters.ProviderFilter @@ -47,7 +50,9 @@ class ProviderViewSet(CustomFieldModelViewSet): # class CircuitTypeViewSet(ModelViewSet): - queryset = CircuitType.objects.all() + queryset = CircuitType.objects.annotate( + circuit_count=Count('circuits') + ) serializer_class = serializers.CircuitTypeSerializer filterset_class = filters.CircuitTypeFilter diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 4decb7166..4323feafc 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -51,7 +51,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet): class Meta: model = CircuitType - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet): diff --git a/netbox/circuits/migrations/0015_custom_tag_models.py b/netbox/circuits/migrations/0015_custom_tag_models.py new file mode 100644 index 000000000..11bde72ff --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index cd9cc694a..0297790b5 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -6,7 +6,7 @@ from taggit.managers import TaggableManager from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES from dcim.fields import ASNField 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.utils import serialize_object 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' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) 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' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 0810f0ff9..e53c2c402 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -61,7 +61,7 @@ class ProviderTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['circuit_count', 'id', 'name', 'slug', 'url'] ) def test_create_provider(self): @@ -162,7 +162,7 @@ class CircuitTypeTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['circuit_count', 'id', 'name', 'slug', 'url'] ) def test_create_circuittype(self): diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 65ae6d7db..cb0ea0a32 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -4,13 +4,15 @@ from django.test import Client, TestCase from django.urls import reverse from circuits.models import Circuit, CircuitType, Provider +from utilities.testing import create_test_user class ProviderTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['circuits.view_provider']) self.client = Client() + self.client.force_login(user) Provider.objects.bulk_create([ Provider(name='Provider 1', slug='provider-1', asn=65001), @@ -38,8 +40,9 @@ class ProviderTestCase(TestCase): class CircuitTypeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['circuits.view_circuittype']) self.client = Client() + self.client.force_login(user) CircuitType.objects.bulk_create([ CircuitType(name='Circuit Type 1', slug='circuit-type-1'), @@ -58,8 +61,9 @@ class CircuitTypeTestCase(TestCase): class CircuitTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['circuits.view_circuit']) self.client = Client() + self.client.force_login(user) provider = Provider(name='Provider 1', slug='provider-1', asn=65001) provider.save() @@ -84,8 +88,8 @@ class CircuitTestCase(TestCase): response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) 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) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 440960d64..c142a831a 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -40,10 +40,11 @@ urlpatterns = [ path(r'circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations + path(r'circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), path(r'circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path(r'circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - path(r'circuit-terminations//connect/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), + path(r'circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), path(r'circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 661f78e8e..219b3d888 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,9 +1,11 @@ +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render +from django.utils.decorators import method_decorator from django.views.generic import View from extras.models import Graph, GRAPH_TYPE_PROVIDER @@ -20,7 +22,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider # Providers # -class ProviderListView(ObjectListView): +class ProviderListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'circuits.view_provider' queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filter = filters.ProviderFilter filter_form = forms.ProviderFilterForm @@ -28,7 +31,8 @@ class ProviderListView(ObjectListView): template_name = 'circuits/provider_list.html' -class ProviderView(View): +class ProviderView(PermissionRequiredMixin, View): + permission_required = 'circuits.view_provider' def get(self, request, slug): @@ -93,7 +97,8 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Circuit Types # -class CircuitTypeListView(ObjectListView): +class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'circuits.view_circuittype' queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable template_name = 'circuits/circuittype_list.html' @@ -128,7 +133,8 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Circuits # -class CircuitListView(ObjectListView): +class CircuitListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'circuits.view_circuit' queryset = Circuit.objects.select_related( 'provider', 'type', 'tenant' ).prefetch_related( @@ -140,7 +146,8 @@ class CircuitListView(ObjectListView): template_name = 'circuits/circuit_list.html' -class CircuitView(View): +class CircuitView(PermissionRequiredMixin, View): + permission_required = 'circuits.view_circuit' def get(self, request, pk): diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index e53259e94..cf22916ad 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -3,8 +3,8 @@ from rest_framework import serializers from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.models import ( 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 @@ -21,7 +21,9 @@ __all__ = [ 'NestedInterfaceSerializer', 'NestedManufacturerSerializer', 'NestedPlatformSerializer', + 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', + 'NestedPowerPanelSerializer', 'NestedPowerPortSerializer', 'NestedRackGroupSerializer', 'NestedRackRoleSerializer', @@ -40,10 +42,11 @@ __all__ = [ class NestedRegionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + site_count = serializers.IntegerField(read_only=True) class Meta: model = Region - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'site_count'] class NestedSiteSerializer(WritableNestedSerializer): @@ -60,26 +63,29 @@ class NestedSiteSerializer(WritableNestedSerializer): class NestedRackGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackGroup - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'rack_count'] class NestedRackRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'rack_count'] class NestedRackSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') + device_count = serializers.IntegerField(read_only=True) class Meta: 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): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') + devicetype_count = serializers.IntegerField(read_only=True) class Meta: model = Manufacturer - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'devicetype_count'] class NestedDeviceTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer(read_only=True) + device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType - fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name'] + fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] class NestedRearPortTemplateSerializer(WritableNestedSerializer): @@ -125,18 +133,22 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): class NestedDeviceRoleSerializer(WritableNestedSerializer): 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: model = DeviceRole - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] class NestedPlatformSerializer(WritableNestedSerializer): 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: model = Platform - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] class NestedDeviceSerializer(WritableNestedSerializer): @@ -243,7 +255,29 @@ class NestedCableSerializer(serializers.ModelSerializer): class NestedVirtualChassisSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer() + member_count = serializers.IntegerField(read_only=True) class Meta: 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'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d8bf68e12..e9526fa41 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField @@ -7,8 +8,9 @@ from dcim.constants import * from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, 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 ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -36,6 +38,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): ) return None + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_connected_endpoint(self, obj): """ Return the appropriate serializer for the type of connected object. @@ -56,10 +59,11 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): class RegionSerializer(serializers.ModelSerializer): parent = NestedRegionSerializer(required=False, allow_null=True) + site_count = serializers.IntegerField(read_only=True) class Meta: model = Region - fields = ['id', 'name', 'slug', 'parent'] + fields = ['id', 'name', 'slug', 'parent', 'site_count'] class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -68,19 +72,20 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(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: model = Site fields = [ 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', '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): site = NestedSiteSerializer() + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackGroup - fields = ['id', 'name', 'slug', 'site'] + fields = ['id', 'name', 'slug', 'site', 'rack_count'] class RackRoleSerializer(ValidatedModelSerializer): + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole - fields = ['id', 'name', 'slug', 'color'] + fields = ['id', 'name', 'slug', 'color', 'rack_count'] class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -113,13 +120,15 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_CHOICES, required=False) tags = TagListSerializerField(required=False) + device_count = serializers.IntegerField(read_only=True) + powerfeed_count = serializers.IntegerField(read_only=True) class Meta: model = Rack fields = [ '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', - '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 # prevents facility_id from being interpreted as a required field. @@ -166,23 +175,26 @@ class RackReservationSerializer(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: model = Manufacturer - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'devicetype_count', 'inventoryitem_count', 'platform_count'] class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() 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) + device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType fields = [ '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: model = PowerPortTemplate - fields = ['id', 'device_type', 'name'] + fields = ['id', 'device_type', 'name', 'maximum_draw', 'allocated_draw'] class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() + power_port = PowerPortTemplateSerializer( + required=False + ) + feed_leg = ChoiceField( + choices=POWERFEED_LEG_CHOICES, + required=False, + allow_null=True + ) class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name'] + fields = ['id', 'device_type', 'name', 'power_port', 'feed_leg'] class InterfaceTemplateSerializer(ValidatedModelSerializer): 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: model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'type', 'form_factor', 'mgmt_only'] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -259,18 +281,25 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # class DeviceRoleSerializer(ValidatedModelSerializer): + device_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceRole - fields = ['id', 'name', 'slug', 'color', 'vm_role'] + fields = ['id', 'name', 'slug', 'color', 'vm_role', 'device_count', 'virtualmachine_count'] class PlatformSerializer(ValidatedModelSerializer): manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) + device_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: 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): @@ -313,6 +342,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): return data + @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) def get_parent_device(self, obj): try: device_bay = obj.parent_bay @@ -335,6 +365,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): 'custom_fields', 'config_context', 'created', 'last_updated', ] + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_config_context(self, obj): return obj.get_config_context() @@ -347,8 +378,8 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) class Meta: model = ConsoleServerPort 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: model = ConsolePort 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): 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: model = PowerOutlet 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: model = PowerPort 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): 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) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -409,9 +454,9 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = Interface 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() @@ -476,7 +521,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): class Meta: 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 + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_termination_a(self, obj): return self._get_termination(obj, 'a') + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_termination_b(self, obj): return self._get_termination(obj, 'b') @@ -569,6 +616,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): model = Interface fields = ['interface_a', 'interface_b', 'connection_status'] + @swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer) def get_interface_a(self, obj): context = {'request': self.context['request']} return NestedInterfaceSerializer(instance=obj, context=context).data @@ -581,7 +629,61 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): master = NestedDeviceSerializer() tags = TagListSerializerField(required=False) + member_count = serializers.IntegerField(read_only=True) class Meta: 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', + ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 006a61bad..fd55d9b05 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -68,6 +68,10 @@ router.register(r'cables', views.CableViewSet) # Virtual chassis router.register(r'virtual-chassis', views.VirtualChassisViewSet) +# Power +router.register(r'power-panels', views.PowerPanelViewSet) +router.register(r'power-feeds', views.PowerFeedViewSet) + # Miscellaneous router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8964e7fcb..05ca76479 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,7 +1,7 @@ from collections import OrderedDict 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.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -12,19 +12,24 @@ from rest_framework.mixins import ListModelMixin from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ViewSet +from circuits.models import Circuit from dcim import filters from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, 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.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE +from ipam.models import Prefix, VLAN from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, ) +from utilities.utils import get_subquery +from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -41,8 +46,10 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): (DeviceType, ['subdevice_role']), (FrontPort, ['type']), (FrontPortTemplate, ['type']), - (Interface, ['form_factor', 'mode']), - (InterfaceTemplate, ['form_factor']), + (Interface, ['type', 'mode']), + (InterfaceTemplate, ['type']), + (PowerOutlet, ['feed_leg']), + (PowerOutletTemplate, ['feed_leg']), (PowerPort, ['connection_status']), (Rack, ['outer_unit', 'status', 'type', 'width']), (RearPort, ['type']), @@ -90,7 +97,9 @@ class CableTraceMixin(object): # class RegionViewSet(ModelViewSet): - queryset = Region.objects.all() + queryset = Region.objects.annotate( + site_count=Count('sites') + ) serializer_class = serializers.RegionSerializer filterset_class = filters.RegionFilter @@ -100,7 +109,18 @@ class RegionViewSet(ModelViewSet): # 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 filterset_class = filters.SiteFilter @@ -120,7 +140,9 @@ class SiteViewSet(CustomFieldModelViewSet): # class RackGroupViewSet(ModelViewSet): - queryset = RackGroup.objects.select_related('site') + queryset = RackGroup.objects.select_related('site').annotate( + rack_count=Count('racks') + ) serializer_class = serializers.RackGroupSerializer filterset_class = filters.RackGroupFilter @@ -130,7 +152,9 @@ class RackGroupViewSet(ModelViewSet): # class RackRoleViewSet(ModelViewSet): - queryset = RackRole.objects.all() + queryset = RackRole.objects.annotate( + rack_count=Count('racks') + ) serializer_class = serializers.RackRoleSerializer filterset_class = filters.RackRoleFilter @@ -140,7 +164,14 @@ class RackRoleViewSet(ModelViewSet): # 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 filterset_class = filters.RackFilter @@ -189,7 +220,11 @@ class RackReservationViewSet(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 filterset_class = filters.ManufacturerFilter @@ -199,7 +234,9 @@ class ManufacturerViewSet(ModelViewSet): # 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 filterset_class = filters.DeviceTypeFilter @@ -261,7 +298,10 @@ class DeviceBayTemplateViewSet(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 filterset_class = filters.DeviceRoleFilter @@ -271,7 +311,10 @@ class DeviceRoleViewSet(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 filterset_class = filters.PlatformFilter @@ -291,16 +334,23 @@ class DeviceViewSet(CustomFieldModelViewSet): 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'] if request.query_params.get('brief', False): 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') def napalm(self, request, pk): @@ -400,7 +450,7 @@ class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): class PowerPortViewSet(CableTraceMixin, ModelViewSet): queryset = PowerPort.objects.select_related( - 'device', 'connected_endpoint__device', 'cable' + 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable' ).prefetch_related( 'tags' ) @@ -490,7 +540,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): queryset = PowerPort.objects.select_related( 'device', 'connected_endpoint__device' ).filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ) serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerConnectionFilter @@ -525,10 +575,40 @@ class CableViewSet(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 +# +# 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 # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index ea7bded2b..8ffc249bd 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -66,204 +66,204 @@ IFACE_ORDERING_CHOICES = [ [IFACE_ORDERING_NAME, 'Name (alphabetically)'] ] -# Interface form factors +# Interface types # Virtual -IFACE_FF_VIRTUAL = 0 -IFACE_FF_LAG = 200 +IFACE_TYPE_VIRTUAL = 0 +IFACE_TYPE_LAG = 200 # 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 -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 -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 -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 -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 -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 -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 -IFACE_FF_OTHER = 32767 +IFACE_TYPE_OTHER = 32767 -IFACE_FF_CHOICES = [ +IFACE_TYPE_CHOICES = [ [ '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)', [ - [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)', [ - [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', [ - [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', [ - [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', [ - [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', [ - [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', [ - [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', [ - [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', [ - [IFACE_FF_OTHER, 'Other'], + [IFACE_TYPE_OTHER, 'Other'], ] ], ] VIRTUAL_IFACE_TYPES = [ - IFACE_FF_VIRTUAL, - IFACE_FF_LAG, + IFACE_TYPE_VIRTUAL, + IFACE_TYPE_LAG, ] 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 @@ -429,7 +429,7 @@ CABLE_TERMINATION_TYPE_CHOICES = { COMPATIBLE_TERMINATION_TYPES = { 'consoleport': ['consoleserverport', 'frontport', 'rearport'], 'consoleserverport': ['consoleport', 'frontport', 'rearport'], - 'powerport': ['poweroutlet'], + 'powerport': ['poweroutlet', 'powerfeed'], 'poweroutlet': ['powerport'], 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], @@ -452,3 +452,41 @@ RACK_DIMENSION_UNIT_CHOICES = ( (LENGTH_UNIT_MILLIMETER, 'Millimeters'), (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'), +) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index ec1e03983..f1c02e713 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -9,16 +9,15 @@ from extras.filters import CustomFieldFilterSet from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant 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 .constants import * from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, 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: model = Region - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class SiteFilter(TenancyFilterSet, CustomFieldFilterSet): @@ -67,7 +66,10 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet): class Meta: 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): if not value.strip(): @@ -104,14 +106,14 @@ class RackGroupFilter(NameSlugSearchFilterSet): class Meta: model = RackGroup - fields = ['site_id', 'name', 'slug'] + fields = ['id', 'name', 'slug'] class RackRoleFilter(NameSlugSearchFilterSet): class Meta: model = RackRole - fields = ['name', 'slug', 'color'] + fields = ['id', 'name', 'slug', 'color'] class RackFilter(TenancyFilterSet, CustomFieldFilterSet): @@ -123,7 +125,6 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet): method='search', label='Search', ) - facility_id = NullableCharFieldFilter() site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -158,14 +159,13 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet): to_field_name='slug', label='Role (slug)', ) - asset_tag = NullableCharFieldFilter() tag = TagFilter() class Meta: model = Rack 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): @@ -245,7 +245,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet): class Meta: model = Manufacturer - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class DeviceTypeFilter(CustomFieldFilterSet): @@ -343,63 +343,63 @@ class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate - fields = ['name'] + fields = ['id', 'name'] class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate - fields = ['name'] + fields = ['id', 'name'] class PowerPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate - fields = ['name'] + fields = ['id', 'name', 'maximum_draw', 'allocated_draw'] class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = PowerOutletTemplate - fields = ['name'] + fields = ['id', 'name', 'feed_leg'] class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate - fields = ['name', 'form_factor', 'mgmt_only'] + fields = ['id', 'name', 'type', 'mgmt_only'] class FrontPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = FrontPortTemplate - fields = ['name', 'type'] + fields = ['id', 'name', 'type'] class RearPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = RearPortTemplate - fields = ['name', 'type'] + fields = ['id', 'name', 'type', 'positions'] class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate - fields = ['name'] + fields = ['id', 'name'] class DeviceRoleFilter(NameSlugSearchFilterSet): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color', 'vm_role'] + fields = ['id', 'name', 'slug', 'color', 'vm_role'] class PlatformFilter(NameSlugSearchFilterSet): @@ -417,7 +417,7 @@ class PlatformFilter(NameSlugSearchFilterSet): class Meta: model = Platform - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug', 'napalm_driver'] class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): @@ -465,8 +465,6 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): to_field_name='slug', label='Platform (slug)', ) - name = NullableCharFieldFilter() - asset_tag = NullableCharFieldFilter() region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region__in', @@ -498,10 +496,6 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): queryset=Rack.objects.all(), label='Rack (ID)', ) - position = django_filters.ChoiceFilter( - choices=DEVICE_POSITION_CHOICES, - null_label='Non-racked' - ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), label='VM cluster (ID)', @@ -561,7 +555,7 @@ class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): class Meta: model = Device - fields = ['serial', 'face'] + fields = ['id', 'name', 'serial', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): @@ -638,7 +632,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet): if not value.strip(): return queryset return queryset.filter( - Q(name__icontains=value) + Q(name__icontains=value) | + Q(description__icontains=value) ) @@ -651,7 +646,7 @@ class ConsolePortFilter(DeviceComponentFilterSet): class Meta: model = ConsolePort - fields = ['name', 'connection_status'] + fields = ['id', 'name', 'description', 'connection_status'] class ConsoleServerPortFilter(DeviceComponentFilterSet): @@ -663,7 +658,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet): class Meta: model = ConsoleServerPort - fields = ['name', 'connection_status'] + fields = ['id', 'name', 'description', 'connection_status'] class PowerPortFilter(DeviceComponentFilterSet): @@ -675,7 +670,7 @@ class PowerPortFilter(DeviceComponentFilterSet): class Meta: model = PowerPort - fields = ['name', 'connection_status'] + fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status'] class PowerOutletFilter(DeviceComponentFilterSet): @@ -687,7 +682,7 @@ class PowerOutletFilter(DeviceComponentFilterSet): class Meta: model = PowerOutlet - fields = ['name', 'connection_status'] + fields = ['id', 'name', 'feed_leg', 'description', 'connection_status'] class InterfaceFilter(django_filters.FilterSet): @@ -713,9 +708,9 @@ class InterfaceFilter(django_filters.FilterSet): lookup_expr='isnull', 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( field_name='lag', @@ -735,20 +730,21 @@ class InterfaceFilter(django_filters.FilterSet): method='filter_vlan', label='Assigned VID' ) - form_factor = django_filters.MultipleChoiceFilter( - choices=IFACE_FF_CHOICES, + type = django_filters.MultipleChoiceFilter( + choices=IFACE_TYPE_CHOICES, null_value=None ) class Meta: 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): if not value.strip(): return queryset return queryset.filter( - Q(name__icontains=value) + Q(name__icontains=value) | + Q(description__icontains=value) ).distinct() def filter_device(self, queryset, name, value): @@ -777,13 +773,12 @@ class InterfaceFilter(django_filters.FilterSet): Q(tagged_vlans__vid=value) ) - def filter_type(self, queryset, name, value): + def filter_kind(self, queryset, name, value): value = value.strip().lower() 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()) def _mac_address(self, queryset, name, value): @@ -806,7 +801,7 @@ class FrontPortFilter(DeviceComponentFilterSet): class Meta: model = FrontPort - fields = ['name', 'type'] + fields = ['id', 'name', 'type', 'description'] class RearPortFilter(DeviceComponentFilterSet): @@ -818,14 +813,14 @@ class RearPortFilter(DeviceComponentFilterSet): class Meta: model = RearPort - fields = ['name', 'type'] + fields = ['id', 'name', 'type', 'positions', 'description'] class DeviceBayFilter(DeviceComponentFilterSet): class Meta: model = DeviceBay - fields = ['name'] + fields = ['id', 'name', 'description'] class InventoryItemFilter(DeviceComponentFilterSet): @@ -856,11 +851,10 @@ class InventoryItemFilter(DeviceComponentFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) - asset_tag = NullableCharFieldFilter() class Meta: 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): if not value.strip(): @@ -906,7 +900,7 @@ class VirtualChassisFilter(django_filters.FilterSet): class Meta: model = VirtualChassis - fields = ['domain'] + fields = ['id', 'domain'] def search(self, queryset, name, value): if not value.strip(): @@ -926,6 +920,9 @@ class CableFilter(django_filters.FilterSet): type = django_filters.MultipleChoiceFilter( choices=CABLE_TYPE_CHOICES ) + status = django_filters.MultipleChoiceFilter( + choices=CONNECTION_STATUS_CHOICES + ) color = django_filters.MultipleChoiceFilter( choices=COLOR_CHOICES ) @@ -940,7 +937,7 @@ class CableFilter(django_filters.FilterSet): class Meta: model = Cable - fields = ['type', 'status', 'color', 'length', 'length_unit'] + fields = ['id', 'label', 'length', 'length_unit'] def search(self, queryset, name, value): if not value.strip(): @@ -1003,14 +1000,14 @@ class PowerConnectionFilter(django_filters.FilterSet): def filter_site(self, queryset, name, value): if not value.strip(): 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): if not value.strip(): return queryset return queryset.filter( 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(_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) diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 215fbb702..ece19a83c 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -902,7 +902,7 @@ "fields": { "device_type": 1, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -912,7 +912,7 @@ "fields": { "device_type": 1, "name": "fxp0 (RE1)", - "form_factor": 800, + "type": 800, "mgmt_only": true } }, @@ -922,7 +922,7 @@ "fields": { "device_type": 1, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false } }, @@ -932,7 +932,7 @@ "fields": { "device_type": 2, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -942,7 +942,7 @@ "fields": { "device_type": 2, "name": "fxp0 (RE1)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -952,7 +952,7 @@ "fields": { "device_type": 2, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false } }, @@ -962,7 +962,7 @@ "fields": { "device_type": 3, "name": "em0", - "form_factor": 800, + "type": 800, "mgmt_only": true } }, @@ -972,7 +972,7 @@ "fields": { "device_type": 3, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -982,7 +982,7 @@ "fields": { "device_type": 3, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -992,7 +992,7 @@ "fields": { "device_type": 3, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1002,7 +1002,7 @@ "fields": { "device_type": 3, "name": "et-0/0/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1012,7 +1012,7 @@ "fields": { "device_type": 3, "name": "et-0/0/4", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1022,7 +1022,7 @@ "fields": { "device_type": 3, "name": "et-0/0/5", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1032,7 +1032,7 @@ "fields": { "device_type": 3, "name": "et-0/0/6", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1042,7 +1042,7 @@ "fields": { "device_type": 3, "name": "et-0/0/7", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1052,7 +1052,7 @@ "fields": { "device_type": 3, "name": "et-0/0/8", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1062,7 +1062,7 @@ "fields": { "device_type": 3, "name": "et-0/0/9", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1072,7 +1072,7 @@ "fields": { "device_type": 3, "name": "et-0/0/10", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1082,7 +1082,7 @@ "fields": { "device_type": 3, "name": "et-0/0/11", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1092,7 +1092,7 @@ "fields": { "device_type": 3, "name": "et-0/0/12", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1102,7 +1102,7 @@ "fields": { "device_type": 3, "name": "et-0/0/13", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1112,7 +1112,7 @@ "fields": { "device_type": 3, "name": "et-0/0/14", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1122,7 +1122,7 @@ "fields": { "device_type": 3, "name": "et-0/0/15", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1132,7 +1132,7 @@ "fields": { "device_type": 3, "name": "et-0/0/16", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1142,7 +1142,7 @@ "fields": { "device_type": 3, "name": "et-0/0/17", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1152,7 +1152,7 @@ "fields": { "device_type": 3, "name": "et-0/0/18", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1162,7 +1162,7 @@ "fields": { "device_type": 3, "name": "et-0/0/19", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1172,7 +1172,7 @@ "fields": { "device_type": 3, "name": "et-0/0/20", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1182,7 +1182,7 @@ "fields": { "device_type": 3, "name": "et-0/0/21", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1192,7 +1192,7 @@ "fields": { "device_type": 3, "name": "et-0/0/22", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1202,7 +1202,7 @@ "fields": { "device_type": 3, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1212,7 +1212,7 @@ "fields": { "device_type": 3, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1222,7 +1222,7 @@ "fields": { "device_type": 3, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1232,7 +1232,7 @@ "fields": { "device_type": 3, "name": "et-0/1/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1242,7 +1242,7 @@ "fields": { "device_type": 3, "name": "et-0/2/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1252,7 +1252,7 @@ "fields": { "device_type": 3, "name": "et-0/2/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1262,7 +1262,7 @@ "fields": { "device_type": 3, "name": "et-0/2/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1272,7 +1272,7 @@ "fields": { "device_type": 3, "name": "et-0/2/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1282,7 +1282,7 @@ "fields": { "device_type": 4, "name": "em0", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -1292,7 +1292,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1302,7 +1302,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1312,7 +1312,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1322,7 +1322,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1332,7 +1332,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1342,7 +1342,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1352,7 +1352,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/6", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1362,7 +1362,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/7", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1372,7 +1372,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/8", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1382,7 +1382,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/9", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1392,7 +1392,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/10", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1402,7 +1402,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/11", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1412,7 +1412,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/12", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1422,7 +1422,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/13", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1432,7 +1432,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/14", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1442,7 +1442,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/15", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1452,7 +1452,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/16", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1462,7 +1462,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/17", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1472,7 +1472,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/18", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1482,7 +1482,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/19", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1492,7 +1492,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/20", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1502,7 +1502,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/21", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1512,7 +1512,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/22", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1522,7 +1522,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/23", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1532,7 +1532,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/24", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1542,7 +1542,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/25", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1552,7 +1552,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/26", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1562,7 +1562,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/27", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1572,7 +1572,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/28", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1582,7 +1582,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/29", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1592,7 +1592,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/30", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1602,7 +1602,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/31", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1612,7 +1612,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/32", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1622,7 +1622,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/33", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1632,7 +1632,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/34", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1642,7 +1642,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/35", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1652,7 +1652,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/36", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1662,7 +1662,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/37", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1672,7 +1672,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/38", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1682,7 +1682,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/39", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1692,7 +1692,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/40", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1702,7 +1702,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/41", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1712,7 +1712,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/42", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1722,7 +1722,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/43", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1732,7 +1732,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/44", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1742,7 +1742,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/45", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1752,7 +1752,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/46", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1762,7 +1762,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/47", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1772,7 +1772,7 @@ "fields": { "device_type": 4, "name": "et-0/0/48", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1782,7 +1782,7 @@ "fields": { "device_type": 4, "name": "et-0/0/49", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1792,7 +1792,7 @@ "fields": { "device_type": 4, "name": "et-0/0/50", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1802,7 +1802,7 @@ "fields": { "device_type": 4, "name": "et-0/0/51", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1812,7 +1812,7 @@ "fields": { "device_type": 5, "name": "eth0", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -1822,7 +1822,7 @@ "fields": { "device_type": 6, "name": "Net", - "form_factor": 800, + "type": 800, "mgmt_only": true } }, @@ -2667,7 +2667,7 @@ "fields": { "device": 1, "name": "PEM0", - "connected_endpoint": 25, + "_connected_poweroutlet": 25, "connection_status": true } }, @@ -2677,7 +2677,7 @@ "fields": { "device": 1, "name": "PEM1", - "connected_endpoint": 49, + "_connected_poweroutlet": 49, "connection_status": true } }, @@ -2687,7 +2687,7 @@ "fields": { "device": 1, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2697,7 +2697,7 @@ "fields": { "device": 1, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2707,7 +2707,7 @@ "fields": { "device": 2, "name": "PEM0", - "connected_endpoint": 26, + "_connected_poweroutlet": 26, "connection_status": true } }, @@ -2717,7 +2717,7 @@ "fields": { "device": 2, "name": "PEM1", - "connected_endpoint": 50, + "_connected_poweroutlet": 50, "connection_status": true } }, @@ -2727,7 +2727,7 @@ "fields": { "device": 2, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2737,7 +2737,7 @@ "fields": { "device": 2, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2747,7 +2747,7 @@ "fields": { "device": 4, "name": "PSU0", - "connected_endpoint": 28, + "_connected_poweroutlet": 28, "connection_status": true } }, @@ -2757,7 +2757,7 @@ "fields": { "device": 4, "name": "PSU1", - "connected_endpoint": 52, + "_connected_poweroutlet": 52, "connection_status": true } }, @@ -2767,7 +2767,7 @@ "fields": { "device": 5, "name": "PSU0", - "connected_endpoint": 56, + "_connected_poweroutlet": 56, "connection_status": true } }, @@ -2777,7 +2777,7 @@ "fields": { "device": 5, "name": "PSU1", - "connected_endpoint": 32, + "_connected_poweroutlet": 32, "connection_status": true } }, @@ -2787,7 +2787,7 @@ "fields": { "device": 3, "name": "PSU0", - "connected_endpoint": 27, + "_connected_poweroutlet": 27, "connection_status": true } }, @@ -2797,7 +2797,7 @@ "fields": { "device": 3, "name": "PSU1", - "connected_endpoint": 51, + "_connected_poweroutlet": 51, "connection_status": true } }, @@ -2807,7 +2807,7 @@ "fields": { "device": 7, "name": "PEM0", - "connected_endpoint": 53, + "_connected_poweroutlet": 53, "connection_status": true } }, @@ -2817,7 +2817,7 @@ "fields": { "device": 7, "name": "PEM1", - "connected_endpoint": 29, + "_connected_poweroutlet": 29, "connection_status": true } }, @@ -2827,7 +2827,7 @@ "fields": { "device": 7, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2837,7 +2837,7 @@ "fields": { "device": 7, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2847,7 +2847,7 @@ "fields": { "device": 8, "name": "PEM0", - "connected_endpoint": 54, + "_connected_poweroutlet": 54, "connection_status": true } }, @@ -2857,7 +2857,7 @@ "fields": { "device": 8, "name": "PEM1", - "connected_endpoint": 30, + "_connected_poweroutlet": 30, "connection_status": true } }, @@ -2867,7 +2867,7 @@ "fields": { "device": 8, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2877,7 +2877,7 @@ "fields": { "device": 8, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2887,7 +2887,7 @@ "fields": { "device": 6, "name": "PSU0", - "connected_endpoint": 55, + "_connected_poweroutlet": 55, "connection_status": true } }, @@ -2897,7 +2897,7 @@ "fields": { "device": 6, "name": "PSU1", - "connected_endpoint": 31, + "_connected_poweroutlet": 31, "connection_status": true } }, @@ -2907,7 +2907,7 @@ "fields": { "device": 9, "name": "PSU", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -3301,7 +3301,7 @@ "fields": { "device": 1, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -3312,7 +3312,7 @@ "fields": { "device": 1, "name": "fxp0 (RE1)", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -3323,7 +3323,7 @@ "fields": { "device": 1, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false, "description": "" } @@ -3334,7 +3334,7 @@ "fields": { "device": 1, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "TEST" } @@ -3345,7 +3345,7 @@ "fields": { "device": 1, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3356,7 +3356,7 @@ "fields": { "device": 1, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3367,7 +3367,7 @@ "fields": { "device": 1, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3378,7 +3378,7 @@ "fields": { "device": 1, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3389,7 +3389,7 @@ "fields": { "device": 1, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3400,7 +3400,7 @@ "fields": { "device": 2, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -3411,7 +3411,7 @@ "fields": { "device": 2, "name": "fxp0 (RE1)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -3422,7 +3422,7 @@ "fields": { "device": 2, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false, "description": "" } @@ -3434,7 +3434,7 @@ "device": 3, "name": "em0", "mac_address": "00-00-00-AA-BB-CC", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -3445,7 +3445,7 @@ "fields": { "device": 3, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3456,7 +3456,7 @@ "fields": { "device": 3, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3467,7 +3467,7 @@ "fields": { "device": 3, "name": "et-0/0/10", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3478,7 +3478,7 @@ "fields": { "device": 3, "name": "et-0/0/11", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3489,7 +3489,7 @@ "fields": { "device": 3, "name": "et-0/0/12", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3500,7 +3500,7 @@ "fields": { "device": 3, "name": "et-0/0/13", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3511,7 +3511,7 @@ "fields": { "device": 3, "name": "et-0/0/14", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3522,7 +3522,7 @@ "fields": { "device": 3, "name": "et-0/0/15", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3533,7 +3533,7 @@ "fields": { "device": 3, "name": "et-0/0/16", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3544,7 +3544,7 @@ "fields": { "device": 3, "name": "et-0/0/17", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3555,7 +3555,7 @@ "fields": { "device": 3, "name": "et-0/0/18", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3566,7 +3566,7 @@ "fields": { "device": 3, "name": "et-0/0/19", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3577,7 +3577,7 @@ "fields": { "device": 3, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3588,7 +3588,7 @@ "fields": { "device": 3, "name": "et-0/0/20", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3599,7 +3599,7 @@ "fields": { "device": 3, "name": "et-0/0/21", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3610,7 +3610,7 @@ "fields": { "device": 3, "name": "et-0/0/22", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3621,7 +3621,7 @@ "fields": { "device": 3, "name": "et-0/0/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3632,7 +3632,7 @@ "fields": { "device": 3, "name": "et-0/0/4", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3643,7 +3643,7 @@ "fields": { "device": 3, "name": "et-0/0/5", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3654,7 +3654,7 @@ "fields": { "device": 3, "name": "et-0/0/6", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3665,7 +3665,7 @@ "fields": { "device": 3, "name": "et-0/0/7", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3676,7 +3676,7 @@ "fields": { "device": 3, "name": "et-0/0/8", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3687,7 +3687,7 @@ "fields": { "device": 3, "name": "et-0/0/9", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3698,7 +3698,7 @@ "fields": { "device": 3, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3709,7 +3709,7 @@ "fields": { "device": 3, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3720,7 +3720,7 @@ "fields": { "device": 3, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3731,7 +3731,7 @@ "fields": { "device": 3, "name": "et-0/1/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3742,7 +3742,7 @@ "fields": { "device": 3, "name": "et-0/2/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3753,7 +3753,7 @@ "fields": { "device": 3, "name": "et-0/2/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3764,7 +3764,7 @@ "fields": { "device": 3, "name": "et-0/2/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3775,7 +3775,7 @@ "fields": { "device": 3, "name": "et-0/2/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3786,7 +3786,7 @@ "fields": { "device": 4, "name": "em0", - "form_factor": 1000, + "type": 1000, "mac_address": "ff-ee-dd-33-22-11", "mgmt_only": true, "description": "" @@ -3798,7 +3798,7 @@ "fields": { "device": 4, "name": "et-0/0/48", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3809,7 +3809,7 @@ "fields": { "device": 4, "name": "et-0/0/49", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3820,7 +3820,7 @@ "fields": { "device": 4, "name": "et-0/0/50", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3831,7 +3831,7 @@ "fields": { "device": 4, "name": "et-0/0/51", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3842,7 +3842,7 @@ "fields": { "device": 4, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3853,7 +3853,7 @@ "fields": { "device": 4, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3864,7 +3864,7 @@ "fields": { "device": 4, "name": "xe-0/0/10", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3875,7 +3875,7 @@ "fields": { "device": 4, "name": "xe-0/0/11", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3886,7 +3886,7 @@ "fields": { "device": 4, "name": "xe-0/0/12", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3897,7 +3897,7 @@ "fields": { "device": 4, "name": "xe-0/0/13", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3908,7 +3908,7 @@ "fields": { "device": 4, "name": "xe-0/0/14", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3919,7 +3919,7 @@ "fields": { "device": 4, "name": "xe-0/0/15", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3930,7 +3930,7 @@ "fields": { "device": 4, "name": "xe-0/0/16", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3941,7 +3941,7 @@ "fields": { "device": 4, "name": "xe-0/0/17", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3952,7 +3952,7 @@ "fields": { "device": 4, "name": "xe-0/0/18", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3963,7 +3963,7 @@ "fields": { "device": 4, "name": "xe-0/0/19", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3974,7 +3974,7 @@ "fields": { "device": 4, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3985,7 +3985,7 @@ "fields": { "device": 4, "name": "xe-0/0/20", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3996,7 +3996,7 @@ "fields": { "device": 4, "name": "xe-0/0/21", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4007,7 +4007,7 @@ "fields": { "device": 4, "name": "xe-0/0/22", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4018,7 +4018,7 @@ "fields": { "device": 4, "name": "xe-0/0/23", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4029,7 +4029,7 @@ "fields": { "device": 4, "name": "xe-0/0/24", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4040,7 +4040,7 @@ "fields": { "device": 4, "name": "xe-0/0/25", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4051,7 +4051,7 @@ "fields": { "device": 4, "name": "xe-0/0/26", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4062,7 +4062,7 @@ "fields": { "device": 4, "name": "xe-0/0/27", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4073,7 +4073,7 @@ "fields": { "device": 4, "name": "xe-0/0/28", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4084,7 +4084,7 @@ "fields": { "device": 4, "name": "xe-0/0/29", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4095,7 +4095,7 @@ "fields": { "device": 4, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4106,7 +4106,7 @@ "fields": { "device": 4, "name": "xe-0/0/30", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4117,7 +4117,7 @@ "fields": { "device": 4, "name": "xe-0/0/31", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4128,7 +4128,7 @@ "fields": { "device": 4, "name": "xe-0/0/32", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4139,7 +4139,7 @@ "fields": { "device": 4, "name": "xe-0/0/33", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4150,7 +4150,7 @@ "fields": { "device": 4, "name": "xe-0/0/34", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4161,7 +4161,7 @@ "fields": { "device": 4, "name": "xe-0/0/35", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4172,7 +4172,7 @@ "fields": { "device": 4, "name": "xe-0/0/36", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4183,7 +4183,7 @@ "fields": { "device": 4, "name": "xe-0/0/37", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4194,7 +4194,7 @@ "fields": { "device": 4, "name": "xe-0/0/38", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4205,7 +4205,7 @@ "fields": { "device": 4, "name": "xe-0/0/39", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4216,7 +4216,7 @@ "fields": { "device": 4, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4227,7 +4227,7 @@ "fields": { "device": 4, "name": "xe-0/0/40", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4238,7 +4238,7 @@ "fields": { "device": 4, "name": "xe-0/0/41", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4249,7 +4249,7 @@ "fields": { "device": 4, "name": "xe-0/0/42", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4260,7 +4260,7 @@ "fields": { "device": 4, "name": "xe-0/0/43", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4271,7 +4271,7 @@ "fields": { "device": 4, "name": "xe-0/0/44", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4282,7 +4282,7 @@ "fields": { "device": 4, "name": "xe-0/0/45", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4293,7 +4293,7 @@ "fields": { "device": 4, "name": "xe-0/0/46", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4304,7 +4304,7 @@ "fields": { "device": 4, "name": "xe-0/0/47", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4315,7 +4315,7 @@ "fields": { "device": 4, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4326,7 +4326,7 @@ "fields": { "device": 4, "name": "xe-0/0/6", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4337,7 +4337,7 @@ "fields": { "device": 4, "name": "xe-0/0/7", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4348,7 +4348,7 @@ "fields": { "device": 4, "name": "xe-0/0/8", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4359,7 +4359,7 @@ "fields": { "device": 4, "name": "xe-0/0/9", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4370,7 +4370,7 @@ "fields": { "device": 5, "name": "em0", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -4381,7 +4381,7 @@ "fields": { "device": 5, "name": "et-0/0/48", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4392,7 +4392,7 @@ "fields": { "device": 5, "name": "et-0/0/49", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4403,7 +4403,7 @@ "fields": { "device": 5, "name": "et-0/0/50", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4414,7 +4414,7 @@ "fields": { "device": 5, "name": "et-0/0/51", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4425,7 +4425,7 @@ "fields": { "device": 5, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4436,7 +4436,7 @@ "fields": { "device": 5, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4447,7 +4447,7 @@ "fields": { "device": 5, "name": "xe-0/0/10", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4458,7 +4458,7 @@ "fields": { "device": 5, "name": "xe-0/0/11", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4469,7 +4469,7 @@ "fields": { "device": 5, "name": "xe-0/0/12", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4480,7 +4480,7 @@ "fields": { "device": 5, "name": "xe-0/0/13", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4491,7 +4491,7 @@ "fields": { "device": 5, "name": "xe-0/0/14", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4502,7 +4502,7 @@ "fields": { "device": 5, "name": "xe-0/0/15", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4513,7 +4513,7 @@ "fields": { "device": 5, "name": "xe-0/0/16", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4524,7 +4524,7 @@ "fields": { "device": 5, "name": "xe-0/0/17", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4535,7 +4535,7 @@ "fields": { "device": 5, "name": "xe-0/0/18", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4546,7 +4546,7 @@ "fields": { "device": 5, "name": "xe-0/0/19", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4557,7 +4557,7 @@ "fields": { "device": 5, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4568,7 +4568,7 @@ "fields": { "device": 5, "name": "xe-0/0/20", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4579,7 +4579,7 @@ "fields": { "device": 5, "name": "xe-0/0/21", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4590,7 +4590,7 @@ "fields": { "device": 5, "name": "xe-0/0/22", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4601,7 +4601,7 @@ "fields": { "device": 5, "name": "xe-0/0/23", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4612,7 +4612,7 @@ "fields": { "device": 5, "name": "xe-0/0/24", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4623,7 +4623,7 @@ "fields": { "device": 5, "name": "xe-0/0/25", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4634,7 +4634,7 @@ "fields": { "device": 5, "name": "xe-0/0/26", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4645,7 +4645,7 @@ "fields": { "device": 5, "name": "xe-0/0/27", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4656,7 +4656,7 @@ "fields": { "device": 5, "name": "xe-0/0/28", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4667,7 +4667,7 @@ "fields": { "device": 5, "name": "xe-0/0/29", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4678,7 +4678,7 @@ "fields": { "device": 5, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4689,7 +4689,7 @@ "fields": { "device": 5, "name": "xe-0/0/30", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4700,7 +4700,7 @@ "fields": { "device": 5, "name": "xe-0/0/31", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4711,7 +4711,7 @@ "fields": { "device": 5, "name": "xe-0/0/32", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4722,7 +4722,7 @@ "fields": { "device": 5, "name": "xe-0/0/33", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4733,7 +4733,7 @@ "fields": { "device": 5, "name": "xe-0/0/34", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4744,7 +4744,7 @@ "fields": { "device": 5, "name": "xe-0/0/35", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4755,7 +4755,7 @@ "fields": { "device": 5, "name": "xe-0/0/36", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4766,7 +4766,7 @@ "fields": { "device": 5, "name": "xe-0/0/37", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4777,7 +4777,7 @@ "fields": { "device": 5, "name": "xe-0/0/38", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4788,7 +4788,7 @@ "fields": { "device": 5, "name": "xe-0/0/39", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4799,7 +4799,7 @@ "fields": { "device": 5, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4810,7 +4810,7 @@ "fields": { "device": 5, "name": "xe-0/0/40", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4821,7 +4821,7 @@ "fields": { "device": 5, "name": "xe-0/0/41", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4832,7 +4832,7 @@ "fields": { "device": 5, "name": "xe-0/0/42", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4843,7 +4843,7 @@ "fields": { "device": 5, "name": "xe-0/0/43", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4854,7 +4854,7 @@ "fields": { "device": 5, "name": "xe-0/0/44", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4865,7 +4865,7 @@ "fields": { "device": 5, "name": "xe-0/0/45", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4876,7 +4876,7 @@ "fields": { "device": 5, "name": "xe-0/0/46", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4887,7 +4887,7 @@ "fields": { "device": 5, "name": "xe-0/0/47", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4898,7 +4898,7 @@ "fields": { "device": 5, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4909,7 +4909,7 @@ "fields": { "device": 5, "name": "xe-0/0/6", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4920,7 +4920,7 @@ "fields": { "device": 5, "name": "xe-0/0/7", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4931,7 +4931,7 @@ "fields": { "device": 5, "name": "xe-0/0/8", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4942,7 +4942,7 @@ "fields": { "device": 5, "name": "xe-0/0/9", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4953,7 +4953,7 @@ "fields": { "device": 6, "name": "em0", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -4964,7 +4964,7 @@ "fields": { "device": 6, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4975,7 +4975,7 @@ "fields": { "device": 6, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4986,7 +4986,7 @@ "fields": { "device": 6, "name": "et-0/0/10", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4997,7 +4997,7 @@ "fields": { "device": 6, "name": "et-0/0/11", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5008,7 +5008,7 @@ "fields": { "device": 6, "name": "et-0/0/12", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5019,7 +5019,7 @@ "fields": { "device": 6, "name": "et-0/0/13", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5030,7 +5030,7 @@ "fields": { "device": 6, "name": "et-0/0/14", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5041,7 +5041,7 @@ "fields": { "device": 6, "name": "et-0/0/15", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5052,7 +5052,7 @@ "fields": { "device": 6, "name": "et-0/0/16", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5063,7 +5063,7 @@ "fields": { "device": 6, "name": "et-0/0/17", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5074,7 +5074,7 @@ "fields": { "device": 6, "name": "et-0/0/18", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5085,7 +5085,7 @@ "fields": { "device": 6, "name": "et-0/0/19", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5096,7 +5096,7 @@ "fields": { "device": 6, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5107,7 +5107,7 @@ "fields": { "device": 6, "name": "et-0/0/20", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5118,7 +5118,7 @@ "fields": { "device": 6, "name": "et-0/0/21", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5129,7 +5129,7 @@ "fields": { "device": 6, "name": "et-0/0/22", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5140,7 +5140,7 @@ "fields": { "device": 6, "name": "et-0/0/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5151,7 +5151,7 @@ "fields": { "device": 6, "name": "et-0/0/4", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5162,7 +5162,7 @@ "fields": { "device": 6, "name": "et-0/0/5", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5173,7 +5173,7 @@ "fields": { "device": 6, "name": "et-0/0/6", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5184,7 +5184,7 @@ "fields": { "device": 6, "name": "et-0/0/7", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5195,7 +5195,7 @@ "fields": { "device": 6, "name": "et-0/0/8", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5206,7 +5206,7 @@ "fields": { "device": 6, "name": "et-0/0/9", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5217,7 +5217,7 @@ "fields": { "device": 6, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5228,7 +5228,7 @@ "fields": { "device": 6, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5239,7 +5239,7 @@ "fields": { "device": 6, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5250,7 +5250,7 @@ "fields": { "device": 6, "name": "et-0/1/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5261,7 +5261,7 @@ "fields": { "device": 6, "name": "et-0/2/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5272,7 +5272,7 @@ "fields": { "device": 6, "name": "et-0/2/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5283,7 +5283,7 @@ "fields": { "device": 6, "name": "et-0/2/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5294,7 +5294,7 @@ "fields": { "device": 6, "name": "et-0/2/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5305,7 +5305,7 @@ "fields": { "device": 7, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -5316,7 +5316,7 @@ "fields": { "device": 7, "name": "fxp0 (RE1)", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -5327,7 +5327,7 @@ "fields": { "device": 7, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false, "description": "" } @@ -5338,7 +5338,7 @@ "fields": { "device": 8, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -5349,7 +5349,7 @@ "fields": { "device": 8, "name": "fxp0 (RE1)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -5360,7 +5360,7 @@ "fields": { "device": 8, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false, "description": "" } @@ -5371,7 +5371,7 @@ "fields": { "device": 2, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5382,7 +5382,7 @@ "fields": { "device": 2, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5393,7 +5393,7 @@ "fields": { "device": 2, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5404,7 +5404,7 @@ "fields": { "device": 2, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5415,7 +5415,7 @@ "fields": { "device": 2, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5426,7 +5426,7 @@ "fields": { "device": 2, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5437,7 +5437,7 @@ "fields": { "device": 8, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5448,7 +5448,7 @@ "fields": { "device": 8, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5459,7 +5459,7 @@ "fields": { "device": 8, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5470,7 +5470,7 @@ "fields": { "device": 8, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5481,7 +5481,7 @@ "fields": { "device": 8, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5492,7 +5492,7 @@ "fields": { "device": 8, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5503,7 +5503,7 @@ "fields": { "device": 2, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5514,7 +5514,7 @@ "fields": { "device": 2, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5525,7 +5525,7 @@ "fields": { "device": 2, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5536,7 +5536,7 @@ "fields": { "device": 2, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5547,7 +5547,7 @@ "fields": { "device": 2, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5558,7 +5558,7 @@ "fields": { "device": 2, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5569,7 +5569,7 @@ "fields": { "device": 8, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5580,7 +5580,7 @@ "fields": { "device": 8, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5591,7 +5591,7 @@ "fields": { "device": 8, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5602,7 +5602,7 @@ "fields": { "device": 8, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5613,7 +5613,7 @@ "fields": { "device": 8, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5624,7 +5624,7 @@ "fields": { "device": 8, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5635,7 +5635,7 @@ "fields": { "device": 7, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5646,7 +5646,7 @@ "fields": { "device": 7, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5657,7 +5657,7 @@ "fields": { "device": 7, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5668,7 +5668,7 @@ "fields": { "device": 7, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5679,7 +5679,7 @@ "fields": { "device": 7, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5690,7 +5690,7 @@ "fields": { "device": 7, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5701,7 +5701,7 @@ "fields": { "device": 9, "name": "eth0", - "form_factor": 1000, + "type": 1000, "mac_address": "44-55-66-77-88-99", "mgmt_only": true, "description": "" @@ -5713,7 +5713,7 @@ "fields": { "device": 11, "name": "Net", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -5724,7 +5724,7 @@ "fields": { "device": 12, "name": "Net", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f10418d57..1ae7b76f4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -10,6 +10,7 @@ from mptt.forms import TreeNodeChoiceField from taggit.forms import TagField from timezone_field import TimeZoneFormField +from circuits.models import Circuit, Provider from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm @@ -17,18 +18,17 @@ from tenancy.forms import TenancyFilterForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( 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 .constants import * from .models import ( Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, 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+\}' @@ -937,7 +937,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', + 'device_type', 'name', 'maximum_draw', 'allocated_draw', ] widgets = { 'device_type': forms.HiddenInput(), @@ -951,16 +951,29 @@ class PowerPortTemplateCreateForm(ComponentForm): class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): + power_port = forms.ModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False + ) class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', + 'device_type', 'name', 'power_port', 'feed_leg', ] widgets = { '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): name_pattern = ExpandableNameField( @@ -973,11 +986,11 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'form_factor', 'mgmt_only', + 'device_type', 'name', 'type', 'mgmt_only', ] widgets = { 'device_type': forms.HiddenInput(), - 'form_factor': StaticSelect2(), + 'type': StaticSelect2(), } @@ -985,8 +998,8 @@ class InterfaceTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) - form_factor = forms.ChoiceField( - choices=IFACE_FF_CHOICES, + type = forms.ChoiceField( + choices=IFACE_TYPE_CHOICES, widget=StaticSelect2() ) mgmt_only = forms.BooleanField( @@ -1000,8 +1013,8 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InterfaceTemplate.objects.all(), 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, widget=StaticSelect2() ) @@ -1785,8 +1798,8 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): - form_factor = forms.ChoiceField( - choices=IFACE_FF_CHOICES, + type = forms.ChoiceField( + choices=IFACE_TYPE_CHOICES, widget=StaticSelect2() ) enabled = forms.BooleanField( @@ -1821,7 +1834,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePort fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1832,6 +1845,10 @@ class ConsolePortCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + description = forms.CharField( + max_length=100, + required=False + ) tags = TagField( required=False ) @@ -1849,7 +1866,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1860,11 +1877,31 @@ class ConsoleServerPortCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + description = forms.CharField( + max_length=100, + required=False + ) tags = TagField( 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): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), @@ -1891,7 +1928,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1902,6 +1939,20 @@ class PowerPortCreateForm(ComponentForm): name_pattern = ExpandableNameField( 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( required=False ) @@ -1912,6 +1963,10 @@ class PowerPortCreateForm(ComponentForm): # class PowerOutletForm(BootstrapMixin, forms.ModelForm): + power_port = forms.ModelChoiceField( + queryset=PowerPort.objects.all(), + required=False + ) tags = TagField( required=False ) @@ -1919,21 +1974,69 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'power_port', 'feed_leg', 'description', 'tags', ] widgets = { '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): name_pattern = ExpandableNameField( 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( 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): pk = forms.ModelMultipleChoiceField( @@ -1961,12 +2064,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface 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', ] widgets = { 'device': forms.HiddenInput(), - 'form_factor': StaticSelect2(), + 'type': StaticSelect2(), 'lag': StaticSelect2(), 'mode': StaticSelect2(), } @@ -1984,12 +2087,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): if self.is_bound: device = Device.objects.get(pk=self.data['device']) 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: device = self.instance.device 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): @@ -2101,8 +2204,8 @@ class InterfaceCreateForm(ComponentForm, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - form_factor = forms.ChoiceField( - choices=IFACE_FF_CHOICES, + type = forms.ChoiceField( + choices=IFACE_TYPE_CHOICES, widget=StaticSelect2(), ) 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) if self.parent is not None: 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: self.fields['lag'].queryset = Interface.objects.none() @@ -2164,8 +2267,8 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=Interface.objects.all(), 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, widget=StaticSelect2() ) @@ -2217,7 +2320,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): if device is not None: self.fields['lag'].queryset = Interface.objects.filter( device__in=[device, device.get_vc_master()], - form_factor=IFACE_FF_LAG + type=IFACE_TYPE_LAG ) else: self.fields['lag'].choices = [] @@ -2440,7 +2543,10 @@ class RearPortBulkDisconnectForm(ConfirmationForm): # 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( queryset=Site.objects.all(), 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/{{termination_b_type}}s/', + api_url='/api/dcim/console-ports/', disabled_indicator='cable', - conditional_query_params={ - 'termination_b_type__interface': 'type=physical', + ) + ) + + +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( + label='Name', + widget=APISelect( + api_url='/api/dcim/power-ports/', + disabled_indicator='cable', + ) + ) + + +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: model = Cable 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): @@ -2752,7 +3015,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBay fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -3101,3 +3364,356 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): 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 + ) diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py index 53f627a5b..e1124b84e 100644 --- a/netbox/dcim/managers.py +++ b/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 wireless). """ - return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) + return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES) class InterfaceManager(Manager): diff --git a/netbox/dcim/migrations/0070_custom_tag_models.py b/netbox/dcim/migrations/0070_custom_tag_models.py new file mode 100644 index 000000000..ee78bed02 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/migrations/0071_device_components_add_description.py b/netbox/dcim/migrations/0071_device_components_add_description.py new file mode 100644 index 000000000..d38f8a88b --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/dcim/migrations/0072_powerfeeds.py b/netbox/dcim/migrations/0072_powerfeeds.py new file mode 100644 index 000000000..63662684d --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/migrations/0073_interface_form_factor_to_type.py b/netbox/dcim/migrations/0073_interface_form_factor_to_type.py new file mode 100644 index 000000000..38016ea00 --- /dev/null +++ b/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', + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f8e8a028e..e1d98a2d4 100644 --- a/netbox/dcim/models.py +++ b/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.validators import MaxValueValidator, MinValueValidator 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 mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager 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.managers import NaturalOrderingManager from utilities.models import ChangeLoggedModel @@ -46,6 +46,10 @@ class ComponentTemplateModel(models.Model): class ComponentModel(models.Model): + description = models.CharField( + max_length=100, + blank=True + ) class Meta: abstract = True @@ -319,7 +323,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ '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): 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 @@ -566,7 +544,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ '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()) 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): """ @@ -914,7 +911,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', @@ -1049,6 +1046,18 @@ class PowerPortTemplate(ComponentTemplateModel): name = models.CharField( 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() @@ -1072,6 +1081,19 @@ class PowerOutletTemplate(ComponentTemplateModel): name = models.CharField( 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() @@ -1082,6 +1104,14 @@ class PowerOutletTemplate(ComponentTemplateModel): def __str__(self): 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): """ @@ -1095,9 +1125,9 @@ class InterfaceTemplate(ComponentTemplateModel): name = models.CharField( 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( default=False, @@ -1113,6 +1143,22 @@ class InterfaceTemplate(ComponentTemplateModel): def __str__(self): 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): """ @@ -1455,7 +1501,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ '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()] ) 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()] ) RearPort.objects.bulk_create([ @@ -1758,9 +1804,9 @@ class ConsolePort(CableTermination, ComponentModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name'] + csv_headers = ['device', 'name', 'description'] class Meta: ordering = ['device', 'name'] @@ -1776,6 +1822,7 @@ class ConsolePort(CableTermination, ComponentModel): return ( self.device.identifier, self.name, + self.description, ) @@ -1801,9 +1848,9 @@ class ConsoleServerPort(CableTermination, ComponentModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name'] + csv_headers = ['device', 'name', 'description'] class Meta: unique_together = ['device', 'name'] @@ -1818,6 +1865,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): return ( self.device.identifier, self.name, + self.description, ) @@ -1837,22 +1885,41 @@ class PowerPort(CableTermination, ComponentModel): name = models.CharField( 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', on_delete=models.SET_NULL, related_name='connected_endpoint', blank=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( choices=CONNECTION_STATUS_CHOICES, blank=True ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name'] + csv_headers = ['device', 'name', 'maximum_draw', 'allocated_draw', 'description'] class Meta: ordering = ['device', 'name'] @@ -1868,8 +1935,76 @@ class PowerPort(CableTermination, ComponentModel): return ( self.device.identifier, 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 @@ -1887,15 +2022,28 @@ class PowerOutlet(CableTermination, ComponentModel): name = models.CharField( 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( choices=CONNECTION_STATUS_CHOICES, blank=True ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name'] + csv_headers = ['device', 'name', 'power_port', 'feed_leg', 'description'] class Meta: unique_together = ['device', 'name'] @@ -1910,8 +2058,19 @@ class PowerOutlet(CableTermination, ComponentModel): return ( self.device.identifier, 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 @@ -1965,9 +2124,9 @@ class Interface(CableTermination, ComponentModel): blank=True, 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( default=True @@ -1988,10 +2147,6 @@ class Interface(CableTermination, ComponentModel): verbose_name='OOB Management', help_text='This interface is used only for out-of-band management' ) - description = models.CharField( - max_length=100, - blank=True - ) mode = models.PositiveSmallIntegerField( choices=IFACE_MODE_CHOICES, blank=True, @@ -2013,10 +2168,10 @@ class Interface(CableTermination, ComponentModel): ) objects = InterfaceManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) 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', ] @@ -2036,7 +2191,7 @@ class Interface(CableTermination, ComponentModel): self.virtual_machine.name if self.virtual_machine else None, self.name, self.lag.name if self.lag else None, - self.get_form_factor_display(), + self.get_type_display(), self.enabled, self.mac_address, self.mtu, @@ -2054,18 +2209,18 @@ class Interface(CableTermination, ComponentModel): raise ValidationError("An interface must belong to either a device or a virtual machine.") # 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({ - 'form_factor': "Virtual machines can only have virtual interfaces." + 'type': "Virtual machines can only have virtual interfaces." }) # 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) ): 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) @@ -2077,15 +2232,15 @@ class Interface(CableTermination, ComponentModel): }) # 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({ - '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 - 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({ - '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()]) ) }) @@ -2131,6 +2286,22 @@ class Interface(CableTermination, ComponentModel): object_data=serialize_object(self) ).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 def connected_endpoint(self): if self._connected_interface: @@ -2161,19 +2332,19 @@ class Interface(CableTermination, ComponentModel): @property def is_connectable(self): - return self.form_factor not in NONCONNECTABLE_IFACE_TYPES + return self.type not in NONCONNECTABLE_IFACE_TYPES @property def is_virtual(self): - return self.form_factor in VIRTUAL_IFACE_TYPES + return self.type in VIRTUAL_IFACE_TYPES @property def is_wireless(self): - return self.form_factor in WIRELESS_IFACE_TYPES + return self.type in WIRELESS_IFACE_TYPES @property def is_lag(self): - return self.form_factor == IFACE_FF_LAG + return self.type == IFACE_TYPE_LAG @property def count_ipaddresses(self): @@ -2208,13 +2379,9 @@ class FrontPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - description = models.CharField( - max_length=100, - blank=True - ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] @@ -2274,13 +2441,9 @@ class RearPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - description = models.CharField( - max_length=100, - blank=True - ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'positions', 'description'] @@ -2327,9 +2490,9 @@ class DeviceBay(ComponentModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'installed_device'] + csv_headers = ['device', 'name', 'installed_device', 'description'] class Meta: ordering = ['device', 'name'] @@ -2346,6 +2509,7 @@ class DeviceBay(ComponentModel): self.device.identifier, self.name, self.installed_device.identifier if self.installed_device else None, + self.description, ) def clean(self): @@ -2415,12 +2579,8 @@ class InventoryItem(ComponentModel): default=False, verbose_name='Discovered' ) - description = models.CharField( - max_length=100, - blank=True - ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', @@ -2467,7 +2627,7 @@ class VirtualChassis(ChangeLoggedModel): blank=True ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['master', 'domain'] @@ -2628,11 +2788,11 @@ class Cable(ChangeLoggedModel): if ( ( isinstance(endpoint_a, Interface) and - endpoint_a.form_factor == IFACE_FF_VIRTUAL + endpoint_a.type == IFACE_TYPE_VIRTUAL ) or ( 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") @@ -2668,6 +2828,14 @@ class Cable(ChangeLoggedModel): def get_status_class(self): 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): """ 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] 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] diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index aec96d04f..aafb35a0f 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -6,8 +6,9 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, 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 = """ @@ -144,6 +145,10 @@ STATUS_LABEL = """ {{ record.get_status_display }} """ +TYPE_LABEL = """ +{{ record.get_type_display }} +""" + DEVICE_PRIMARY_IP = """ {{ record.primary_ip6.address.ip|default:"" }} {% if record.primary_ip6 and record.primary_ip4 %}
{% endif %} @@ -184,6 +189,10 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} """ +POWERPANEL_POWERFEED_COUNT = """ +{{ value }} +""" + # # Regions @@ -290,12 +299,21 @@ class RackDetailTable(RackTable): template_code=RACK_DEVICE_COUNT, 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): fields = ( '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): model = InterfaceTemplate - fields = ('pk', 'name', 'mgmt_only', 'form_factor') + fields = ('pk', 'name', 'mgmt_only', 'type') empty_text = "None" @@ -582,7 +600,7 @@ class ConsoleServerPortTable(BaseTable): class Meta(BaseTable.Meta): model = ConsoleServerPort - fields = ('name',) + fields = ('name', 'description') class PowerPortTable(BaseTable): @@ -596,14 +614,14 @@ class PowerOutletTable(BaseTable): class Meta(BaseTable.Meta): model = PowerOutlet - fields = ('name',) + fields = ('name', 'description') class InterfaceTable(BaseTable): class Meta(BaseTable.Meta): model = Interface - fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description') + fields = ('name', 'type', 'lag', 'enabled', 'mgmt_only', 'description') class FrontPortTable(BaseTable): @@ -713,7 +731,8 @@ class PowerConnectionTable(BaseTable): args=[Accessor('connected_endpoint.device.pk')], verbose_name='PDU' ) - connected_endpoint = tables.Column( + outlet = tables.Column( + accessor=Accessor('_connected_poweroutlet'), verbose_name='Outlet' ) device = tables.LinkColumn( @@ -726,7 +745,7 @@ class PowerConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPort - fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status') + fields = ('pdu', 'outlet', 'device', 'name', 'connection_status') class InterfaceConnectionTable(BaseTable): @@ -801,3 +820,51 @@ class VirtualChassisTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualChassis 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') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index aa57d4790..9c873c886 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,8 +7,8 @@ from dcim.constants import * from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, 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 extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE @@ -47,7 +47,7 @@ class RegionTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'site_count', 'slug', 'url'] ) def test_create_region(self): @@ -285,7 +285,7 @@ class RackGroupTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'rack_count', 'slug', 'url'] ) def test_create_rackgroup(self): @@ -393,7 +393,7 @@ class RackRoleTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'rack_count', 'slug', 'url'] ) def test_create_rackrole(self): @@ -520,7 +520,7 @@ class RackTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['display_name', 'id', 'name', 'url'] + ['device_count', 'display_name', 'id', 'name', 'url'] ) def test_create_rack(self): @@ -746,7 +746,7 @@ class ManufacturerTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['devicetype_count', 'id', 'name', 'slug', 'url'] ) def test_create_manufacturer(self): @@ -855,7 +855,7 @@ class DeviceTypeTest(APITestCase): self.assertEqual( 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): @@ -1569,7 +1569,7 @@ class DeviceRoleTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] ) def test_create_devicerole(self): @@ -1677,7 +1677,7 @@ class PlatformTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] ) def test_create_platform(self): @@ -1791,6 +1791,16 @@ class DeviceTest(APITestCase): site=self.site1, 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): @@ -1806,7 +1816,7 @@ class DeviceTest(APITestCase): url = reverse('dcim-api:device-list') 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): @@ -1832,7 +1842,7 @@ class DeviceTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) 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']) self.assertEqual(device4.device_type_id, data['device_type']) 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) 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[1]['name'], data[1]['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) 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']) self.assertEqual(device1.device_type_id, data['device_type']) self.assertEqual(device1.device_role_id, data['device_role']) @@ -1906,7 +1916,21 @@ class DeviceTest(APITestCase): response = self.client.delete(url, **self.header) 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): @@ -2529,7 +2553,7 @@ class InterfaceTest(APITestCase): def test_update_interface(self): 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 = { @@ -2817,7 +2841,7 @@ class CableTest(APITestCase): ) for device in [self.device1, self.device2]: 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( 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 ) 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): - 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): - 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): - 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): - 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): - 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): - 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): - 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): - 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 self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1') @@ -3433,7 +3457,7 @@ class VirtualChassisTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'master', 'url'] + ['id', 'master', 'member_count', 'url'] ) def test_create_virtualchassis(self): @@ -3508,3 +3532,260 @@ class VirtualChassisTest(APITestCase): self.assertTrue( 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) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 757af61f4..e0af86b20 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -249,7 +249,7 @@ class CableTestCase(TestCase): """ 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) with self.assertRaises(ValidationError): cable.clean() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 79f38a5c9..6e34b8ae9 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,21 +1,22 @@ import urllib.parse -from django.contrib.auth import get_user_model from django.test import Client, TestCase 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 ( Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, Site, Region, VirtualChassis, ) +from utilities.testing import create_test_user class RegionTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_region']) self.client = Client() + self.client.force_login(user) # Create three Regions for i in range(1, 4): @@ -32,8 +33,9 @@ class RegionTestCase(TestCase): class SiteTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_site']) self.client = Client() + self.client.force_login(user) region = Region(name='Region 1', slug='region-1') region.save() @@ -64,8 +66,9 @@ class SiteTestCase(TestCase): class RackGroupTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_rackgroup']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -84,11 +87,12 @@ class RackGroupTestCase(TestCase): self.assertEqual(response.status_code, 200) -class RackTypeTestCase(TestCase): +class RackRoleTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_rackrole']) self.client = Client() + self.client.force_login(user) RackRole.objects.bulk_create([ RackRole(name='Rack Role 1', slug='rack-role-1'), @@ -107,12 +111,9 @@ class RackTypeTestCase(TestCase): class RackReservationTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_rackreservation']) 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.save() @@ -137,8 +138,9 @@ class RackReservationTestCase(TestCase): class RackTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_rack']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -169,8 +171,9 @@ class RackTestCase(TestCase): class ManufacturerTypeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_manufacturer']) self.client = Client() + self.client.force_login(user) Manufacturer.objects.bulk_create([ Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), @@ -189,8 +192,9 @@ class ManufacturerTypeTestCase(TestCase): class DeviceTypeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_devicetype']) self.client = Client() + self.client.force_login(user) manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') manufacturer.save() @@ -221,8 +225,9 @@ class DeviceTypeTestCase(TestCase): class DeviceRoleTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_devicerole']) self.client = Client() + self.client.force_login(user) DeviceRole.objects.bulk_create([ DeviceRole(name='Device Role 1', slug='device-role-1'), @@ -241,8 +246,9 @@ class DeviceRoleTestCase(TestCase): class PlatformTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_platform']) self.client = Client() + self.client.force_login(user) Platform.objects.bulk_create([ Platform(name='Platform 1', slug='platform-1'), @@ -261,8 +267,9 @@ class PlatformTestCase(TestCase): class DeviceTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_device']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -303,8 +310,9 @@ class DeviceTestCase(TestCase): class InventoryItemTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_inventoryitem']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -337,18 +345,13 @@ class InventoryItemTestCase(TestCase): response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) 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): def setUp(self): - + user = create_test_user(permissions=['dcim.view_cable']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -367,17 +370,17 @@ class CableTestCase(TestCase): device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole) 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() - 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() - 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() - 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() - 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() - 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() 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) -class VirtualMachineTestCase(TestCase): +class VirtualChassisTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_virtualchassis']) self.client = Client() + self.client.force_login(user) site = Site.objects.create(name='Site 1', slug='site-1') manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') @@ -450,9 +454,3 @@ class VirtualMachineTestCase(TestCase): response = self.client.get(url) 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) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 25c3a5a4d..ae1f05757 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -6,7 +6,8 @@ from secrets.views import secret_add from . import views from .models import ( 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' @@ -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.ConsolePortCreateView.as_view(), name='consoleport_add'), path(r'devices//console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - path(r'console-ports//connect/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + path(r'console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path(r'console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), path(r'console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), path(r'console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), @@ -170,8 +171,9 @@ urlpatterns = [ # 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.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), + path(r'devices//console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'), path(r'devices//console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - path(r'console-server-ports//connect/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + path(r'console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path(r'console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), path(r'console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), path(r'console-server-ports//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.PowerPortCreateView.as_view(), name='powerport_add'), path(r'devices//power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - path(r'power-ports//connect/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + path(r'power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path(r'power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path(r'power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), path(r'power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), @@ -190,8 +192,9 @@ urlpatterns = [ # Power outlets path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), path(r'devices//power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), + path(r'devices//power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'), path(r'devices//power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - path(r'power-outlets//connect/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + path(r'power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path(r'power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), path(r'power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), path(r'power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), @@ -203,7 +206,7 @@ urlpatterns = [ path(r'devices//interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), path(r'devices//interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), path(r'devices//interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - path(r'interfaces//connect/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), + path(r'interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path(r'interfaces//', views.InterfaceView.as_view(), name='interface'), path(r'interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path(r'interfaces//assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), @@ -218,7 +221,7 @@ urlpatterns = [ path(r'devices//front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'), path(r'devices//front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), path(r'devices//front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), - path(r'front-ports//connect/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + path(r'front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), path(r'front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), path(r'front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), path(r'front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), @@ -230,7 +233,7 @@ urlpatterns = [ path(r'devices//rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'), path(r'devices//rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), path(r'devices//rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), - path(r'rear-ports//connect/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + path(r'rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path(r'rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path(r'rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), path(r'rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), @@ -280,4 +283,25 @@ urlpatterns = [ path(r'virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), path(r'virtual-chassis-members//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//', views.PowerPanelView.as_view(), name='powerpanel'), + path(r'power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), + path(r'power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), + path(r'power-panels//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//', views.PowerFeedView.as_view(), name='powerfeed'), + path(r'power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), + path(r'power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), + path(r'power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), + ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 3cbb2a508..cf152e646 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,6 +3,7 @@ import re from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction 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.urls import reverse from django.utils.html import escape +from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View @@ -30,8 +32,9 @@ from . import filters, forms, tables from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, 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 # -class RegionListView(ObjectListView): +class RegionListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_region' queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -179,7 +183,8 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Sites # -class SiteListView(ObjectListView): +class SiteListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_site' queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm @@ -187,7 +192,8 @@ class SiteListView(ObjectListView): template_name = 'dcim/site_list.html' -class SiteView(View): +class SiteView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_site' def get(self, request, slug): @@ -259,7 +265,8 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # 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')) filter = filters.RackGroupFilter filter_form = forms.RackGroupFilterForm @@ -297,7 +304,8 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack roles # -class RackRoleListView(ObjectListView): +class RackRoleListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_rackrole' queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable template_name = 'dcim/rackrole_list.html' @@ -332,7 +340,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Racks # -class RackListView(ObjectListView): +class RackListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_rack' queryset = Rack.objects.select_related( 'site', 'group', 'tenant', 'role' ).prefetch_related( @@ -346,10 +355,11 @@ class RackListView(ObjectListView): template_name = 'dcim/rack_list.html' -class RackElevationListView(View): +class RackElevationListView(PermissionRequiredMixin, View): """ Display a set of rack elevations side-by-side. """ + permission_required = 'dcim.view_rack' 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): @@ -399,10 +410,12 @@ class RackView(View): prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() reservations = RackReservation.objects.filter(rack=rack) + power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel') return render(request, 'dcim/rack.html', { 'rack': rack, 'reservations': reservations, + 'power_feeds': power_feeds, 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, @@ -457,7 +470,8 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack reservations # -class RackReservationListView(ObjectListView): +class RackReservationListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_rackreservation' queryset = RackReservation.objects.select_related('rack__site') filter = filters.RackReservationFilter filter_form = forms.RackReservationFilterForm @@ -513,7 +527,8 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Manufacturers # -class ManufacturerListView(ObjectListView): +class ManufacturerListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_manufacturer' queryset = Manufacturer.objects.annotate( devicetype_count=Count('device_types', distinct=True), inventoryitem_count=Count('inventory_items', distinct=True), @@ -552,7 +567,8 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # 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')) filter = filters.DeviceTypeFilter filter_form = forms.DeviceTypeFilterForm @@ -560,7 +576,8 @@ class DeviceTypeListView(ObjectListView): template_name = 'dcim/devicetype_list.html' -class DeviceTypeView(View): +class DeviceTypeView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_devicetype' def get(self, request, pk): @@ -816,7 +833,8 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device roles # -class DeviceRoleListView(ObjectListView): +class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_devicerole' queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable template_name = 'dcim/devicerole_list.html' @@ -851,7 +869,8 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Platforms # -class PlatformListView(ObjectListView): +class PlatformListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_platform' queryset = Platform.objects.all() table = tables.PlatformTable template_name = 'dcim/platform_list.html' @@ -886,7 +905,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Devices # -class DeviceListView(ObjectListView): +class DeviceListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_device' queryset = Device.objects.select_related( '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' -class DeviceView(View): +class DeviceView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_device' def get(self, request, pk): @@ -919,10 +940,10 @@ class DeviceView(View): consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable') # Power ports - power_ports = device.powerports.select_related('connected_endpoint__device', 'cable') + power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable') # Power outlets - poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable') + poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable', 'power_port') # Interfaces 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): @@ -997,7 +1019,7 @@ class DeviceInventoryView(View): class DeviceStatusView(PermissionRequiredMixin, View): - permission_required = 'dcim.napalm_read' + permission_required = ('dcim.view_device', 'dcim.napalm_read') def get(self, request, pk): @@ -1010,7 +1032,7 @@ class DeviceStatusView(PermissionRequiredMixin, View): class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): - permission_required = 'dcim.napalm_read' + permission_required = ('dcim.view_device', 'dcim.napalm_read') def get(self, request, pk): @@ -1027,7 +1049,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): class DeviceConfigView(PermissionRequiredMixin, View): - permission_required = 'dcim.napalm_read' + permission_required = ('dcim.view_device', 'dcim.napalm_read') 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 base_template = 'dcim/device.html' @@ -1163,6 +1186,14 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): 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): permission_required = 'dcim.change_consoleserverport' queryset = ConsoleServerPort.objects.all() @@ -1239,6 +1270,14 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): 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): permission_required = 'dcim.change_poweroutlet' queryset = PowerOutlet.objects.all() @@ -1262,7 +1301,8 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Interfaces # -class InterfaceView(View): +class InterfaceView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_interface' def get(self, request, pk): @@ -1643,7 +1683,8 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie # Cables # -class CableListView(ObjectListView): +class CableListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_cable' queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) @@ -1653,7 +1694,8 @@ class CableListView(ObjectListView): template_name = 'dcim/cable_list.html' -class CableView(View): +class CableView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_cable' 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. """ + permission_required = 'dcim.view_cable' 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' - model = Cable - model_form = forms.CableCreateForm 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 {}'.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): @@ -1737,7 +1840,8 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Connections # -class ConsoleConnectionsListView(ObjectListView): +class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): + permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport') queryset = ConsolePort.objects.select_related( 'device', 'connected_endpoint__device' ).filter( @@ -1767,13 +1871,14 @@ class ConsoleConnectionsListView(ObjectListView): return csv_data -class PowerConnectionsListView(ObjectListView): +class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): + permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet') queryset = PowerPort.objects.select_related( - 'device', 'connected_endpoint__device' + 'device', '_connected_poweroutlet__device' ).filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ).order_by( - 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' + 'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name' ) filter = filters.PowerConnectionFilter filter_form = forms.PowerConnectionFilterForm @@ -1797,7 +1902,8 @@ class PowerConnectionsListView(ObjectListView): return csv_data -class InterfaceConnectionsListView(ObjectListView): +class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.interface' queryset = Interface.objects.select_related( 'device', 'cable', '_connected_interface__device' ).filter( @@ -1839,7 +1945,8 @@ class InterfaceConnectionsListView(ObjectListView): # Inventory items # -class InventoryItemListView(ObjectListView): +class InventoryItemListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_inventoryitem' queryset = InventoryItem.objects.select_related('device', 'manufacturer') filter = filters.InventoryItemFilter filter_form = forms.InventoryItemFilterForm @@ -1894,7 +2001,8 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # 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')) table = tables.VirtualChassisTable filter = filters.VirtualChassisFilter @@ -2123,3 +2231,143 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, 'form': form, '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' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index b4962dfd7..a29d0df09 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from netbox.admin import admin_site 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): @@ -77,6 +77,34 @@ class CustomFieldAdmin(admin.ModelAdmin): 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 {{ obj }}.', + 'url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', + } + + 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 # diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index cca783bc6..abf0d8cf5 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,7 +1,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from taggit.models import Tag from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, @@ -11,6 +11,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.constants import * from extras.models import ( ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, + Tag ) from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup @@ -89,7 +90,7 @@ class TagSerializer(ValidatedModelSerializer): class Meta: 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 + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_parent(self, obj): # Static mapping of models to their nested serializers @@ -237,6 +239,7 @@ class ObjectChangeSerializer(serializers.ModelSerializer): 'object_data', ] + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_changed_object(self, obj): """ Serialize a nested representation of the changed object. diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 1bdcf181b..c135280ea 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -17,6 +17,9 @@ router.APIRootView = ExtrasRootView # Field choices 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 router.register(r'graphs', views.GraphViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 2150cb5b5..44e010cd2 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,3 +1,5 @@ +from collections import OrderedDict + from django.contrib.contenttypes.models import ContentType from django.db.models import Count 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.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet -from taggit.models import Tag from extras import filters 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 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 # @@ -117,7 +149,7 @@ class TopologyMapViewSet(ModelViewSet): class TagViewSet(ModelViewSet): queryset = Tag.objects.annotate( - tagged_items=Count('taggit_taggeditem_items', distinct=True) + tagged_items=Count('extras_taggeditem_items', distinct=True) ) serializer_class = serializers.TagSerializer filterset_class = filters.TagFilter diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 6e6083691..6bb3b9fca 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -7,6 +7,9 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): + + import extras.signals + # Check that we can connect to the configured Redis database if webhooks are enabled. if settings.WEBHOOKS_ENABLED: try: diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 13c15cbba..b72ae8c08 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,13 +1,24 @@ # 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 CF_TYPE_TEXT = 100 @@ -35,6 +46,46 @@ CF_FILTER_CHOICES = ( (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_TYPE_INTERFACE = 100 GRAPH_TYPE_PROVIDER = 200 @@ -47,13 +98,32 @@ GRAPH_TYPE_CHOICES = ( # Models which support export templates 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 @@ -125,13 +195,36 @@ WEBHOOK_CT_CHOICES = ( ) # 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', +] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index d5457a5a6..49e879fe4 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,12 +1,11 @@ import django_filters from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from taggit.models import Tag from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup 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): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 54eee0c5c..261822d28 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -5,19 +5,18 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from taggit.forms import TagField -from taggit.models import Tag from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, - FilterChoiceField, LaxURLField, JSONField, SlugField, + add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, + ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, ) from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, 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): slug = SlugField() + comments = CommentField() class Meta: model = Tag fields = [ - 'name', 'slug', + 'name', 'slug', 'color', 'comments' ] diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index c5a2fa1ec..18c0d0a0a 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -6,7 +6,6 @@ from django import get_version from django.apps import apps from django.conf import settings from django.core.management.base import BaseCommand -from django.db.models import Model APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] @@ -38,16 +37,10 @@ class Command(BaseCommand): for app in APPS: 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 try: diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 9c8e7b69d..b0b5a014d 100644 --- a/netbox/extras/middleware.py +++ b/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.utils import timezone from django.utils.functional import curry +from django_prometheus.models import model_deletes, model_inserts, model_updates from extras.webhooks import enqueue_webhooks from .constants import ( @@ -33,15 +34,20 @@ def _record_object_deleted(request, instance, **kwargs): if hasattr(instance, 'log_change'): instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) + # Enqueue webhooks enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE) + # Increment metric counters + model_deletes.labels(instance._meta.model_name).inc() + 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. 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 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(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 if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) diff --git a/netbox/extras/migrations/0019_tag_taggeditem.py b/netbox/extras/migrations/0019_tag_taggeditem.py new file mode 100644 index 000000000..7f531a737 --- /dev/null +++ b/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')}, + ), + ] diff --git a/netbox/extras/migrations/0020_tag_data.py b/netbox/extras/migrations/0020_tag_data.py new file mode 100644 index 000000000..615ded8e2 --- /dev/null +++ b/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), + ] diff --git a/netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py b/netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py new file mode 100644 index 000000000..26ed34b31 --- /dev/null +++ b/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), + ), + ] diff --git a/netbox/extras/migrations/0022_custom_links.py b/netbox/extras/migrations/0022_custom_links.py new file mode 100644 index 000000000..cd204f50a --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 8d8a05e10..c5df5c2e5 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -13,9 +13,11 @@ from django.template import Template, Context from django.urls import reverse import graphviz from jinja2 import Environment +from taggit.models import TagBase, GenericTaggedItemBase 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 .querysets import ConfigContextQuerySet @@ -24,6 +26,10 @@ from .querysets import ConfigContextQuerySet # Webhooks # +def get_webhook_models(): + return model_names_to_filter_dict(WEBHOOK_MODELS) + + 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 @@ -35,7 +41,7 @@ class Webhook(models.Model): to=ContentType, related_name='webhooks', 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." ) name = models.CharField( @@ -137,12 +143,16 @@ class CustomFieldModel(models.Model): 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): obj_type = models.ManyToManyField( to=ContentType, related_name='custom_fields', 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.' ) type = models.PositiveSmallIntegerField( @@ -303,6 +313,62 @@ class CustomFieldChoice(models.Model): 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 # @@ -348,11 +414,15 @@ class Graph(models.Model): # Export templates # +def get_export_template_models(): + return model_names_to_filter_dict(EXPORTTEMPLATE_MODELS) + + class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS} + limit_choices_to=get_export_template_models ) name = models.CharField( max_length=100 @@ -569,7 +639,7 @@ class TopologyMap(models.Model): from dcim.models import PowerPort # 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' 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_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") + ) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py new file mode 100644 index 000000000..aa173b437 --- /dev/null +++ b/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) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index f6933bf48..a5545693e 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,11 +1,13 @@ import django_tables2 as tables 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 = """ + + + {% if perms.taggit.change_tag %} {% endif %} @@ -71,10 +73,11 @@ class TagTable(BaseTable): attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) + color = ColorColumn() class Meta(BaseTable.Meta): model = Tag - fields = ('pk', 'name', 'items', 'slug', 'actions') + fields = ('pk', 'name', 'items', 'slug', 'color', 'actions') class TaggedItemTable(BaseTable): diff --git a/netbox/extras/templatetags/__init__.py b/netbox/extras/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py new file mode 100644 index 000000000..193c465a5 --- /dev/null +++ b/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 = '{}\n' +GROUP_BUTTON = '
\n' \ + '\n' \ + '\n
\n' + + # Render template + rendered = Environment().from_string(source=template_code).render(**context) + + return mark_safe(rendered) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index cccb00a8a..3fe36c9ef 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,11 +1,10 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status -from taggit.models import Tag from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, 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 utilities.testing import APITestCase diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index b02e787c1..96f3483bc 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -6,9 +6,10 @@ from django.urls import reverse from rest_framework import status 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 utilities.testing import APITestCase +from virtualization.models import VirtualMachine class CustomFieldTest(TestCase): @@ -299,3 +300,33 @@ class CustomFieldAPITest(APITestCase): 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) 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]) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index d478f069c..9d1584226 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -4,17 +4,18 @@ import uuid from django.contrib.auth.models import User from django.test import Client, TestCase from django.urls import reverse -from taggit.models import Tag 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): def setUp(self): - + user = create_test_user(permissions=['extras.view_tag']) self.client = Client() + self.client.force_login(user) Tag.objects.bulk_create([ Tag(name='Tag 1', slug='tag-1'), @@ -36,8 +37,9 @@ class TagTestCase(TestCase): class ConfigContextTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['extras.view_configcontext']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -71,11 +73,9 @@ class ConfigContextTestCase(TestCase): class ObjectChangeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['extras.view_objectchange']) 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.save() diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 5ba7c110c..ad6eabe1e 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,6 +1,8 @@ from django.urls import path from extras import views +from extras.models import Tag + app_name = 'extras' urlpatterns = [ @@ -11,6 +13,7 @@ urlpatterns = [ path(r'tags//', views.TagView.as_view(), name='tag'), path(r'tags//edit/', views.TagEditView.as_view(), name='tag_edit'), path(r'tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), + path(r'tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), # Config contexts path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2f088eb77..3b02035a9 100644 --- a/netbox/extras/views.py +++ b/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.views.generic import View from django_tables2 import RequestConfig -from taggit.models import Tag, TaggedItem from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator @@ -19,7 +18,7 @@ from .forms import ( ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, 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 .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable @@ -28,9 +27,10 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT # Tags # -class TagListView(ObjectListView): +class TagListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'extras.view_tag' queryset = Tag.objects.annotate( - items=Count('taggit_taggeditem_items', distinct=True) + items=Count('extras_taggeditem_items', distinct=True) ).order_by( 'name' ) @@ -69,22 +69,23 @@ class TagView(View): class TagEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'taggit.change_tag' + permission_required = 'extras.change_tag' model = Tag model_form = TagForm default_return_url = 'extras:tag_list' + template_name = 'extras/tag_edit.html' class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'taggit.delete_tag' + permission_required = 'extras.delete_tag' model = Tag default_return_url = 'extras:tag_list' class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'taggit.delete_tag' + permission_required = 'extras.delete_tag' queryset = Tag.objects.annotate( - items=Count('taggit_taggeditem_items') + items=Count('extras_taggeditem_items') ).order_by( 'name' ) @@ -96,7 +97,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Config contexts # -class ConfigContextListView(ObjectListView): +class ConfigContextListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'extras.view_configcontext' queryset = ConfigContext.objects.all() filter = filters.ConfigContextFilter filter_form = ConfigContextFilterForm @@ -104,7 +106,8 @@ class ConfigContextListView(ObjectListView): template_name = 'extras/configcontext_list.html' -class ConfigContextView(View): +class ConfigContextView(PermissionRequiredMixin, View): + permission_required = 'extras.view_configcontext' def get(self, request, pk): @@ -173,7 +176,8 @@ class ObjectConfigContextView(View): # Change logging # -class ObjectChangeListView(ObjectListView): +class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'extras.view_objectchange' queryset = ObjectChange.objects.select_related('user', 'changed_object_type') filter = filters.ObjectChangeFilter filter_form = ObjectChangeFilterForm @@ -181,7 +185,8 @@ class ObjectChangeListView(ObjectListView): template_name = 'extras/objectchange_list.html' -class ObjectChangeView(View): +class ObjectChangeView(PermissionRequiredMixin, View): + permission_required = 'extras.view_objectchange' def get(self, request, pk): @@ -272,10 +277,11 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): # Reports # -class ReportListView(View): +class ReportListView(PermissionRequiredMixin, View): """ 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): @@ -295,10 +301,11 @@ class ReportListView(View): }) -class ReportView(View): +class ReportView(PermissionRequiredMixin, View): """ Display a single Report and its associated ReportResult (if any). """ + permission_required = 'extras.view_reportresult' def get(self, request, name): diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 1ad050866..46090fb2e 100644 --- a/netbox/extras/webhooks.py +++ b/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 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 # Retrieve any applicable Webhooks diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 2ffaa0ae2..aa7c95f1c 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -21,10 +21,11 @@ __all__ = [ class NestedVRFSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') + prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VRF - fields = ['id', 'url', 'name', 'rd'] + fields = ['id', 'url', 'name', 'rd', 'prefix_count'] # @@ -33,10 +34,11 @@ class NestedVRFSerializer(WritableNestedSerializer): class NestedRIRSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') + aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'aggregate_count'] class NestedAggregateSerializer(WritableNestedSerializer): @@ -53,18 +55,21 @@ class NestedAggregateSerializer(WritableNestedSerializer): class NestedRoleSerializer(WritableNestedSerializer): 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: model = Role - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count'] class NestedVLANGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') + vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'vlan_count'] class NestedVLANSerializer(WritableNestedSerializer): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 9b2c45371..fc0c390cf 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -25,12 +25,14 @@ from .nested_serializers import * class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) + ipaddress_count = serializers.IntegerField(read_only=True) + prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VRF 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): + aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR - fields = ['id', 'name', 'slug', 'is_private'] + fields = ['id', 'name', 'slug', 'is_private', 'aggregate_count'] class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -63,18 +66,21 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): # class RoleSerializer(ValidatedModelSerializer): + prefix_count = serializers.IntegerField(read_only=True) + vlan_count = serializers.IntegerField(read_only=True) class Meta: model = Role - fields = ['id', 'name', 'slug', 'weight'] + fields = ['id', 'name', 'slug', 'weight', 'prefix_count', 'vlan_count'] class VLANGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) + vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'site'] + fields = ['id', 'name', 'slug', 'site', 'vlan_count'] validators = [] def validate(self, data): @@ -99,12 +105,13 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False) role = NestedRoleSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) + prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VLAN fields = [ '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 = [] @@ -203,8 +210,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): class Meta: model = IPAddress 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'] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index e846f0489..95c418a10 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.db.models import Count from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.decorators import action @@ -9,6 +10,7 @@ from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.utils import get_subquery from . import serializers @@ -31,7 +33,10 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet): # 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 filterset_class = filters.VRFFilter @@ -41,7 +46,9 @@ class VRFViewSet(CustomFieldModelViewSet): # class RIRViewSet(ModelViewSet): - queryset = RIR.objects.all() + queryset = RIR.objects.annotate( + aggregate_count=Count('aggregates') + ) serializer_class = serializers.RIRSerializer filterset_class = filters.RIRFilter @@ -61,7 +68,10 @@ class AggregateViewSet(CustomFieldModelViewSet): # 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 filterset_class = filters.RoleFilter @@ -71,7 +81,11 @@ class RoleViewSet(ModelViewSet): # 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 filterset_class = filters.PrefixFilter @@ -263,7 +277,9 @@ class IPAddressViewSet(CustomFieldModelViewSet): # class VLANGroupViewSet(ModelViewSet): - queryset = VLANGroup.objects.select_related('site') + queryset = VLANGroup.objects.select_related('site').annotate( + vlan_count=Count('vlans') + ) serializer_class = serializers.VLANGroupSerializer filterset_class = filters.VLANGroupFilter @@ -273,7 +289,13 @@ class VLANGroupViewSet(ModelViewSet): # 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 filterset_class = filters.VLANFilter diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index a5464e4d0..49c547d2d 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -58,6 +58,10 @@ class AggregateFilter(CustomFieldFilterSet): method='search', label='Search', ) + prefix = django_filters.CharFilter( + method='filter_prefix', + label='Prefix', + ) rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), label='RIR (ID)', @@ -85,6 +89,15 @@ class AggregateFilter(CustomFieldFilterSet): pass 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): q = django_filters.CharFilter( @@ -94,7 +107,7 @@ class RoleFilter(NameSlugSearchFilterSet): class Meta: model = Role - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet): @@ -307,12 +320,13 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet): class Meta: model = IPAddress - fields = ['family'] + fields = ['family', 'dns_name'] def search(self, queryset, name, value): if not value.strip(): return queryset qs_filter = ( + Q(dns_name__icontains=value) | Q(description__icontains=value) | Q(address__istartswith=value) ) @@ -367,7 +381,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet): class Meta: model = VLANGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class VLANFilter(TenancyFilterSet, CustomFieldFilterSet): @@ -459,7 +473,7 @@ class ServiceFilter(django_filters.FilterSet): class Meta: model = Service - fields = ['name', 'protocol', 'port'] + fields = ['id', 'name', 'protocol', 'port'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index d7c0b1a0c..7a79caaa0 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -645,8 +645,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) class Meta: model = IPAddress 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 = { 'status': StaticSelect2(), @@ -732,7 +732,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant', + 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', ] widgets = { 'status': StaticSelect2(), @@ -905,13 +905,18 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd required=False, widget=StaticSelect2() ) + dns_name = forms.CharField( + max_length=255, + required=False + ) description = forms.CharField( - max_length=100, required=False + max_length=100, + required=False ) class Meta: nullable_fields = [ - 'vrf', 'role', 'tenant', 'description', + 'vrf', 'role', 'tenant', 'dns_name', 'description', ] diff --git a/netbox/ipam/migrations/0025_custom_tag_models.py b/netbox/ipam/migrations/0025_custom_tag_models.py new file mode 100644 index 000000000..002aefcb2 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/ipam/migrations/0026_prefix_ordering_vrf_nulls_first.py b/netbox/ipam/migrations/0026_prefix_ordering_vrf_nulls_first.py new file mode 100644 index 000000000..17b169b24 --- /dev/null +++ b/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'}, + ), + ] diff --git a/netbox/ipam/migrations/0027_ipaddress_add_dns_name.py b/netbox/ipam/migrations/0027_ipaddress_add_dns_name.py new file mode 100644 index 000000000..534957ce1 --- /dev/null +++ b/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.-]+$')]), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index a2f7bbe07..373128a8f 100644 --- a/netbox/ipam/models.py +++ b/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.validators import MaxValueValidator, MinValueValidator 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.urls import reverse from taggit.managers import TaggableManager 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.utils import serialize_object from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet +from .validators import DNSValidator class VRF(ChangeLoggedModel, CustomFieldModel): @@ -56,7 +57,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] @@ -155,7 +156,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['prefix', 'rir', 'date_added', 'description'] @@ -325,14 +326,14 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): ) objects = PrefixQuerySet.as_manager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', ] class Meta: - ordering = ['vrf', 'family', 'prefix'] + ordering = [F('vrf').asc(nulls_first=True), 'family', 'prefix'] verbose_name_plural = 'prefixes' def __str__(self): @@ -367,11 +368,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): }) def save(self, *args, **kwargs): - if self.prefix: + + if isinstance(self.prefix, netaddr.IPNetwork): + # Clear host bits from prefix self.prefix = self.prefix.cidr - # Infer address family from IPNetwork object + + # Record address family self.family = self.prefix.version + super().save(*args, **kwargs) def to_csv(self): @@ -573,6 +578,13 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): verbose_name='NAT (Inside)', 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( max_length=100, blank=True @@ -584,11 +596,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): ) objects = IPAddressManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', - 'description', + 'dns_name', 'description', ] class Meta: @@ -625,9 +637,14 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): }) 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 + + # Force dns_name to lowercase + self.dns_name = self.dns_name.lower() + super().save(*args, **kwargs) 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.interface.name if self.interface else None, is_primary, + self.dns_name, self.description, ) @@ -812,7 +830,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] @@ -914,7 +932,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description'] diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index bfb2525f2..9fd9bb6c1 100644 --- a/netbox/ipam/querysets.py +++ b/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): """ diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 9578b4407..3906f080f 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -340,7 +340,9 @@ class IPAddressTable(BaseTable): class Meta(BaseTable.Meta): 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 = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } @@ -354,7 +356,8 @@ class IPAddressDetailTable(IPAddressTable): class Meta(IPAddressTable.Meta): 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', ) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index d43a5675a..29368090e 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -41,7 +41,7 @@ class VRFTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'rd', 'url'] + ['id', 'name', 'prefix_count', 'rd', 'url'] ) def test_create_vrf(self): @@ -149,7 +149,7 @@ class RIRTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['aggregate_count', 'id', 'name', 'slug', 'url'] ) def test_create_rir(self): @@ -353,7 +353,7 @@ class RoleTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] ) def test_create_role(self): @@ -792,7 +792,7 @@ class VLANGroupTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'slug', 'url', 'vlan_count'] ) def test_create_vlangroup(self): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 20c16df9b..e14a257d6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/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 ipam.constants import IP_PROTOCOL_TCP from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from utilities.testing import create_test_user class VRFTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_vrf']) self.client = Client() + self.client.force_login(user) VRF.objects.bulk_create([ VRF(name='VRF 1', rd='65000:1'), @@ -41,8 +43,9 @@ class VRFTestCase(TestCase): class RIRTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_rir']) self.client = Client() + self.client.force_login(user) RIR.objects.bulk_create([ RIR(name='RIR 1', slug='rir-1'), @@ -57,18 +60,13 @@ class RIRTestCase(TestCase): response = self.client.get(url) 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): def setUp(self): - + user = create_test_user(permissions=['ipam.view_aggregate']) self.client = Client() + self.client.force_login(user) rir = RIR(name='RIR 1', slug='rir-1') rir.save() @@ -99,8 +97,9 @@ class AggregateTestCase(TestCase): class RoleTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_role']) self.client = Client() + self.client.force_login(user) Role.objects.bulk_create([ Role(name='Role 1', slug='role-1'), @@ -119,8 +118,9 @@ class RoleTestCase(TestCase): class PrefixTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_prefix']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -151,8 +151,9 @@ class PrefixTestCase(TestCase): class IPAddressTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_ipaddress']) self.client = Client() + self.client.force_login(user) vrf = VRF(name='VRF 1', rd='65000:1') vrf.save() @@ -183,8 +184,9 @@ class IPAddressTestCase(TestCase): class VLANGroupTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_vlangroup']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -209,8 +211,9 @@ class VLANGroupTestCase(TestCase): class VLANTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_vlan']) self.client = Client() + self.client.force_login(user) vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1') vlangroup.save() @@ -241,8 +244,9 @@ class VLANTestCase(TestCase): class ServiceTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_service']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py new file mode 100644 index 000000000..6669b7ec5 --- /dev/null +++ b/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' +) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 2f76089a2..d80646bb0 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -113,7 +113,8 @@ def add_available_vlans(vlan_group, vlans): # VRFs # -class VRFListView(ObjectListView): +class VRFListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_vrf' queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter filter_form = forms.VRFFilterForm @@ -121,7 +122,8 @@ class VRFListView(ObjectListView): template_name = 'ipam/vrf_list.html' -class VRFView(View): +class VRFView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_vrf' def get(self, request, pk): @@ -180,7 +182,8 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # RIRs # -class RIRListView(ObjectListView): +class RIRListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_rir' queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filter = filters.RIRFilter filter_form = forms.RIRFilterForm @@ -286,7 +289,8 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Aggregates # -class AggregateListView(ObjectListView): +class AggregateListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_aggregate' queryset = Aggregate.objects.select_related('rir').extra(select={ '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): @@ -398,7 +403,8 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefix/VLAN roles # -class RoleListView(ObjectListView): +class RoleListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_role' queryset = Role.objects.all() table = tables.RoleTable template_name = 'ipam/role_list.html' @@ -433,7 +439,8 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefixes # -class PrefixListView(ObjectListView): +class PrefixListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_prefix' queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter filter_form = forms.PrefixFilterForm @@ -446,7 +453,8 @@ class PrefixListView(ObjectListView): return self.queryset.annotate_depth(limit=limit) -class PrefixView(View): +class PrefixView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_prefix' 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): @@ -531,7 +540,8 @@ class PrefixPrefixesView(View): }) -class PrefixIPAddressesView(View): +class PrefixIPAddressesView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_prefix' def get(self, request, pk): @@ -617,7 +627,8 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # IP addresses # -class IPAddressListView(ObjectListView): +class IPAddressListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_ipaddress' queryset = IPAddress.objects.select_related( 'vrf__tenant', 'tenant', 'nat_inside' ).prefetch_related( @@ -629,7 +640,8 @@ class IPAddressListView(ObjectListView): template_name = 'ipam/ipaddress_list.html' -class IPAddressView(View): +class IPAddressView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_ipaddress' def get(self, request, pk): @@ -788,7 +800,8 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # 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')) filter = filters.VLANGroupFilter filter_form = forms.VLANGroupFilterForm @@ -822,7 +835,9 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_return_url = 'ipam:vlangroup_list' -class VLANGroupVLANsView(View): +class VLANGroupVLANsView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_vlangroup' + def get(self, request, pk): vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk) @@ -861,7 +876,8 @@ class VLANGroupVLANsView(View): # 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') filter = filters.VLANFilter filter_form = forms.VLANFilterForm @@ -869,7 +885,8 @@ class VLANListView(ObjectListView): template_name = 'ipam/vlan_list.html' -class VLANView(View): +class VLANView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_vlan' 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): @@ -954,7 +972,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Services # -class ServiceListView(ObjectListView): +class ServiceListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_service' queryset = Service.objects.select_related('device', 'virtual_machine') filter = filters.ServiceFilter filter_form = forms.ServiceFilterForm @@ -962,7 +981,8 @@ class ServiceListView(ObjectListView): template_name = 'ipam/service_list.html' -class ServiceView(View): +class ServiceView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_service' def get(self, request, pk): diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py index 61796aabd..27a033094 100644 --- a/netbox/netbox/admin.py +++ b/netbox/netbox/admin.py @@ -2,8 +2,6 @@ from django.conf import settings from django.contrib.admin import AdminSite from django.contrib.auth.admin import GroupAdmin, UserAdmin from django.contrib.auth.models import Group, User -from taggit.admin import TagAdmin -from taggit.models import Tag class NetBoxAdminSite(AdminSite): @@ -20,7 +18,6 @@ admin_site = NetBoxAdminSite(name='admin') # Register external models admin_site.register(Group, GroupAdmin) 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) if settings.WEBHOOKS_ENABLED: diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index d8592f341..d20ac6563 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.db.models import QuerySet from rest_framework import authentication, exceptions from rest_framework.pagination import LimitOffsetPagination 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 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): + # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. self.authenticated_users_only = settings.LOGIN_REQUIRED + super().__init__() def has_permission(self, request, view): + # 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 not request.auth.write_enabled: return False + return super().has_permission(request, view) @@ -81,13 +97,8 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): 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: # We're dealing with an iterable, not a QuerySet self.count = len(queryset) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 145ebf0e6..ebc3d4540 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -25,6 +25,17 @@ DATABASE = { # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-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 = '' +# 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) 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_ALLOW_ALL = False CORS_ORIGIN_WHITELIST = [ - # 'hostname.example.com', + # 'https://hostname.example.com', ] CORS_ORIGIN_REGEX_WHITELIST = [ # 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. 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 `.`. 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: # https://docs.djangoproject.com/en/1.11/topics/logging/ LOGGING = {} @@ -107,6 +129,9 @@ MAX_PAGE_SIZE = 1000 # the default value of this setting is derived from the installed location. # 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. NAPALM_USERNAME = '' NAPALM_PASSWORD = '' @@ -125,16 +150,6 @@ PAGINATE_COUNT = 50 # prefer IPv4 instead. 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 # this setting is derived from the installed location. # REPORTS_ROOT = '/opt/netbox/netbox/reports' diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index a2ad1376b..36198a384 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -16,6 +16,7 @@ OBJ_TYPE_CHOICES = ( ('device', 'Devices'), ('virtualchassis', 'Virtual Chassis'), ('cable', 'Cables'), + ('powerfeed', 'Power Feeds'), )), ('IPAM', ( ('vrf', 'VRFs'), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 38aa96657..b29e70701 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,19 +1,37 @@ import logging import os +import platform import socket -import sys import warnings from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured -# Django 2.1 requires Python 3.5+ -if sys.version_info < (3, 5): + +# +# Environment setup +# + +VERSION = '2.6-beta1' + +# 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( - "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: from netbox import configuration except ImportError: @@ -21,22 +39,20 @@ except ImportError: "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation." ) - -VERSION = '2.5.14-dev' - -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( - "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', []) BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '') BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '') @@ -44,6 +60,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only +CACHE_TIMEOUT = getattr(configuration, 'CACHE_TIMEOUT', 900) CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) 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') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) -ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) 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', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) 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_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) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) 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) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') 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') 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 -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 = { - 'default': configuration.DATABASE, + 'default': DATABASE, } + +# +# Redis +# + +REDIS_HOST = REDIS.get('HOST', 'localhost') +REDIS_PORT = REDIS.get('PORT', 6379) +REDIS_PASSWORD = REDIS.get('PASSWORD', '') +REDIS_DATABASE = REDIS.get('DATABASE', 0) +REDIS_CACHE_DATABASE = REDIS.get('CACHE_DATABASE', 1) +REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) +REDIS_SSL = REDIS.get('SSL', False) + + +# # 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_HOST = REDIS.get('HOST', 'localhost') -REDIS_PORT = REDIS.get('PORT', 6379) -REDIS_PASSWORD = REDIS.get('PASSWORD', '') -REDIS_DATABASE = REDIS.get('DATABASE', 0) -REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) -REDIS_SSL = REDIS.get('SSL', False) +# # Email +# + EMAIL_HOST = EMAIL.get('SERVER') EMAIL_PORT = EMAIL.get('PORT', 25) EMAIL_HOST_USER = EMAIL.get('USERNAME') @@ -142,7 +149,11 @@ EMAIL_TIMEOUT = EMAIL.get('TIMEOUT', 10) SERVER_EMAIL = EMAIL.get('FROM_EMAIL') EMAIL_SUBJECT_PREFIX = '[NetBox] ' -# Installed applications + +# +# Django +# + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -151,10 +162,12 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + 'cacheops', 'corsheaders', 'debug_toolbar', 'django_filters', 'django_tables2', + 'django_prometheus', 'mptt', 'rest_framework', 'taggit', @@ -179,6 +192,7 @@ if WEBHOOKS_ENABLED: # Middleware MIDDLEWARE = ( 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'django_prometheus.middleware.PrometheusBeforeMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -191,14 +205,16 @@ MIDDLEWARE = ( 'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.APIVersionMiddleware', 'extras.middleware.ObjectChangeMiddleware', + 'django_prometheus.middleware.PrometheusAfterMiddleware', ) ROOT_URLCONF = 'netbox.urls' +TEMPLATES_DIR = BASE_DIR + '/templates' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR + '/templates'], + 'DIRS': [TEMPLATES_DIR], 'APP_DIRS': True, 'OPTIONS': { '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 LANGUAGE_CODE = 'en-us' USE_I18N = 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_ROOT = BASE_DIR + '/static' STATIC_URL = '/{}static/'.format(BASE_PATH) @@ -244,22 +265,134 @@ MESSAGE_TAGS = { # Authentication URLs 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 +# + FILTERS_NULL_CHOICE_LABEL = 'None' FILTERS_NULL_CHOICE_VALUE = 'null' + +# # Django REST framework (API) +# + REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version REST_FRAMEWORK = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], @@ -284,19 +417,11 @@ REST_FRAMEWORK = { '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 = { 'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema', 'DEFAULT_FIELD_INSPECTORS': [ @@ -310,6 +435,7 @@ SWAGGER_SETTINGS = { 'drf_yasg.inspectors.ChoiceFieldInspector', 'drf_yasg.inspectors.FileFieldInspector', 'drf_yasg.inspectors.DictFieldInspector', + 'drf_yasg.inspectors.SerializerMethodFieldInspector', 'drf_yasg.inspectors.SimpleFieldInspector', '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 +# + INTERNAL_IPS = ( '127.0.0.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) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index efcd17a87..ef2c9ec28 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -74,6 +74,11 @@ if settings.DEBUG: path(r'__debug__/', include(debug_toolbar.urls)), ] +if settings.METRICS_ENABLED: + _patterns += [ + path('', include('django_prometheus.urls')), + ] + # Prepend BASE_PATH urlpatterns = [ path(r'{}'.format(settings.BASE_PATH), include(_patterns)) diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 837d9473d..9d382592d 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -11,13 +11,15 @@ from circuits.filters import CircuitFilter, ProviderFilter from circuits.models import Circuit, Provider from circuits.tables import CircuitTable, ProviderTable from dcim.filters import ( - CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter + CableFilter, DeviceFilter, DeviceTypeFilter, PowerFeedFilter, RackFilter, RackGroupFilter, SiteFilter, + VirtualChassisFilter, ) 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 ( - CableTable, DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable + CableTable, DeviceDetailTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable, + VirtualChassisTable, ) from extras.models import ObjectChange, ReportResult, TopologyMap from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter @@ -94,6 +96,12 @@ SEARCH_TYPES = OrderedDict(( 'table': CableTable, 'url': 'dcim:cable_list', }), + ('powerfeed', { + 'queryset': PowerFeed.objects.all(), + 'filter': PowerFeedFilter, + 'table': PowerFeedTable, + 'url': 'dcim:powerfeed_list', + }), # IPAM ('vrf', { 'queryset': VRF.objects.select_related('tenant'), @@ -166,7 +174,7 @@ class HomeView(View): connected_endpoint__isnull=False ) connected_powerports = PowerPort.objects.filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ) connected_interfaces = Interface.objects.filter( _connected_interface__isnull=False, @@ -182,11 +190,13 @@ class HomeView(View): # DCIM 'rack_count': Rack.objects.count(), + 'devicetype_count': DeviceType.objects.count(), 'device_count': Device.objects.count(), 'interface_connections_count': connected_interfaces.count(), 'cable_count': cables.count(), 'console_connections_count': connected_consoleports.count(), 'power_connections_count': connected_powerports.count(), + 'powerfeed_count': PowerFeed.objects.count(), # IPAM 'vrf_count': VRF.objects.count(), diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index e87811bc1..fcee05e12 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -586,6 +586,7 @@ ul.nav-tabs, ul.nav-pills { /* Fix progress bar margin inside table cells */ td .progress { margin-bottom: 0; + min-width: 100px; } textarea { font-family: Consolas, Lucida Console, monospace; diff --git a/netbox/secrets/api/nested_serializers.py b/netbox/secrets/api/nested_serializers.py index 819546c63..7aa8087da 100644 --- a/netbox/secrets/api/nested_serializers.py +++ b/netbox/secrets/api/nested_serializers.py @@ -10,7 +10,8 @@ __all__ = [ class NestedSecretRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') + secret_count = serializers.IntegerField(read_only=True) class Meta: model = SecretRole - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'secret_count'] diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 1faf85dcf..7a0447a39 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -14,10 +14,11 @@ from .nested_serializers import * # class SecretRoleSerializer(ValidatedModelSerializer): + secret_count = serializers.IntegerField(read_only=True) class Meta: model = SecretRole - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'secret_count'] class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 0c164de07..88537b649 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,6 +1,7 @@ import base64 from Crypto.PublicKey import RSA +from django.db.models import Count from django.http import HttpResponseBadRequest from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated @@ -32,7 +33,9 @@ class SecretsFieldChoicesViewSet(FieldChoicesViewSet): # class SecretRoleViewSet(ModelViewSet): - queryset = SecretRole.objects.all() + queryset = SecretRole.objects.annotate( + secret_count=Count('secrets') + ) serializer_class = serializers.SecretRoleSerializer permission_classes = [IsAuthenticated] filterset_class = filters.SecretRoleFilter diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 6548708b5..628d716db 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -11,10 +11,10 @@ class SecretRoleFilter(NameSlugSearchFilterSet): class Meta: model = SecretRole - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): +class SecretFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/secrets/migrations/0006_custom_tag_models.py b/netbox/secrets/migrations/0006_custom_tag_models.py new file mode 100644 index 000000000..399dcc5bf --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 8190cd1dd..6dcb5abee 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -14,7 +14,7 @@ from django.urls import reverse from django.utils.encoding import force_bytes from taggit.managers import TaggableManager -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, TaggedItem from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -345,7 +345,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) plaintext = None csv_headers = ['device', 'role', 'name', 'plaintext'] diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index c260f1a48..ce0295d83 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -78,7 +78,7 @@ class SecretRoleTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'secret_count', 'slug', 'url'] ) def test_create_secretrole(self): diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index f9985db28..5ba5cdcf6 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -1,25 +1,19 @@ import urllib.parse -from django.contrib.auth import get_user_model from django.test import Client, TestCase from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole +from utilities.testing import create_test_user class SecretRoleTestCase(TestCase): def setUp(self): - - TEST_USERNAME = 'testuser' - TEST_PASSWORD = 'testpassword' - - User = get_user_model() - User.objects.create(username=TEST_USERNAME, email='testuser@example.com', password=TEST_PASSWORD) - + user = create_test_user(permissions=['secrets.view_secretrole']) self.client = Client() - self.client.login(username=TEST_USERNAME, password=TEST_PASSWORD) + self.client.force_login(user) SecretRole.objects.bulk_create([ SecretRole(name='Secret Role 1', slug='secret-role-1'), @@ -29,7 +23,7 @@ class SecretRoleTestCase(TestCase): def test_secretrole_list(self): - url = reverse('secrets:secret_list') + url = reverse('secrets:secretrole_list') response = self.client.get(url, follow=True) self.assertEqual(response.status_code, 200) @@ -38,8 +32,9 @@ class SecretRoleTestCase(TestCase): class SecretTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['secrets.view_secret']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -75,7 +70,7 @@ class SecretTestCase(TestCase): response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True) self.assertEqual(response.status_code, 200) - def test_configcontext(self): + def test_secret(self): secret = Secret.objects.first() response = self.client.get(secret.get_absolute_url(), follow=True) diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 99b725528..b30987935 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,12 +1,11 @@ import base64 from django.contrib import messages -from django.contrib.auth.decorators import permission_required, login_required +from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.decorators import method_decorator from django.views.generic import View from dcim.models import Device @@ -32,7 +31,8 @@ def get_session_key(request): # Secret roles # -class SecretRoleListView(ObjectListView): +class SecretRoleListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'secrets.view_secretrole' queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable template_name = 'secrets/secretrole_list.html' @@ -67,8 +67,8 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Secrets # -@method_decorator(login_required, name='dispatch') -class SecretListView(ObjectListView): +class SecretListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'secrets.view_secret' queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter filter_form = forms.SecretFilterForm @@ -76,8 +76,8 @@ class SecretListView(ObjectListView): template_name = 'secrets/secret_list.html' -@method_decorator(login_required, name='dispatch') -class SecretView(View): +class SecretView(PermissionRequiredMixin, View): + permission_required = 'secrets.view_secret' def get(self, request, pk): @@ -198,7 +198,7 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SecretBulkImportView(BulkImportView): - permission_required = 'ipam.add_vlan' + permission_required = 'secrets.add_secret' model_form = forms.SecretCSVForm table = tables.SecretTable template_name = 'secrets/secret_import.html' diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 890b2a880..a29a2ed7c 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block title %}{{ circuit }}{% endblock %} @@ -41,13 +42,18 @@

{{ circuit }}

{% include 'inc/created_updated.html' with obj=circuit %} +
+ {% custom_links circuit %} +
{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index a3ca77279..d161fdb68 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -55,9 +55,16 @@ {% else %} {% if perms.dcim.add_cable %}
- - Connect - + + + +
{% endif %} Not defined diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 3dd5d973f..a83a5337a 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -1,5 +1,6 @@ {% extends '_base.html' %} {% load static %} +{% load custom_links %} {% load helpers %} {% block title %}{{ provider }}{% endblock %} @@ -47,13 +48,18 @@

{{ provider }}

{% include 'inc/created_updated.html' with obj=provider %} +
+ {% custom_links provider %} +
{% endblock %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 953e6caf4..54e575009 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -23,13 +24,19 @@ {% endif %}

{% block title %}Cable {{ cable }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=cable %} +
+ {% custom_links cable %} +
{% endblock %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index cad396966..b1609f578 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -22,7 +22,7 @@ {% endif %} {% with termination_a=form.instance.termination_a %} -

{% block title %}Connect {{ termination_a.device }} {{ termination_a }}{% endblock %}

+

{% block title %}Connect {{ termination_a.device }} {{ termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}

@@ -101,21 +101,43 @@ B Side
- -
- -
- {% render_field form.termination_b_site %} - {% render_field form.termination_b_rack %} + {% if tabs %} + + {% endif %} + {% if 'termination_b_provider' in form.fields %} + {% render_field form.termination_b_provider %} + {% endif %} + {% if 'termination_b_site' in form.fields %} + {% render_field form.termination_b_site %} + {% endif %} + {% if 'termination_b_rackgroup' in form.fields %} + {% render_field form.termination_b_rackgroup %} + {% endif %} + {% if 'termination_b_rack' in form.fields %} + {% render_field form.termination_b_rack %} + {% endif %} + {% if 'termination_b_device' in form.fields %} + {% render_field form.termination_b_device %} + {% endif %} + {% if 'termination_b_type' in form.fields %} + {% render_field form.termination_b_type %} + {% endif %} + {% if 'termination_b_powerpanel' in form.fields %} + {% render_field form.termination_b_powerpanel %} + {% endif %} + {% if 'termination_b_circuit' in form.fields %} + {% render_field form.termination_b_circuit %} + {% endif %} +
+ +
+

{{ termination_b_type|capfirst }}

- {% render_field form.termination_b_device %} - {% render_field form.termination_b_type %} {% render_field form.termination_b_id %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 047887dd3..5f0bbdfc0 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -1,6 +1,7 @@ {% extends '_base.html' %} {% load static %} {% load helpers %} +{% load custom_links %} {% block title %}{{ device }}{% endblock %} @@ -64,6 +65,9 @@

{{ device }}

{% include 'inc/created_updated.html' with obj=device %} +
+ {% custom_links device %} +
{% endblock %} @@ -332,6 +340,49 @@ {% endif %}
{% endif %} + {% if power_ports and poweroutlets %} +
+
+ Power Utilization +
+ + + + + + + + + {% for pp in power_ports %} + {% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %} + + + + + {% if powerfeed %} + + + {% else %} + + + {% endif %} + + {% for leg in utilization.legs %} + + + + + + {% with phase_available=powerfeed.available_power|divide:3 %} + + {% endwith %} + + {% endfor %} + {% endwith %} + {% endfor %} +
InputOutletsAllocatedAvailableUtilization
{{ pp }}{{ utilization.outlet_count }}{{ utilization.allocated }}VA{{ powerfeed.available_power }}VA{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
Leg {{ leg.name }}{{ leg.outlet_count }}{{ leg.allocated }}{{ powerfeed.available_power|divide:3 }}VA{% utilization_graph leg.allocated|percentage:phase_available %}
+
+ {% endif %} {% if request.user.is_authenticated %}
@@ -445,6 +496,7 @@ {% endif %} Name Status + Description Installed Device @@ -570,6 +622,7 @@ {% endif %} Name + Description Cable Connection @@ -586,6 +639,9 @@ + @@ -625,8 +681,10 @@ {% endif %} Name + Input/Leg + Description Cable - Connection + Connection @@ -641,6 +699,9 @@ + diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index d2bba9562..4764fffa2 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -27,7 +27,7 @@ {{ iface.connected_endpoint.device }} - {{ iface.connected_endpoint }} + {{ iface.connected_endpoint }} {% elif iface.connected_endpoint.circuit %} {% with circuit=iface.connected_endpoint.circuit %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 5b5410457..2e244ac55 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %} @@ -46,13 +47,18 @@ {% endif %}

{{ devicetype.manufacturer }} {{ devicetype.model }}

{% include 'inc/created_updated.html' with obj=devicetype %} +
+ {% custom_links devicetype %} +
{% endblock %} diff --git a/netbox/templates/dcim/inc/cable_trace_end.html b/netbox/templates/dcim/inc/cable_trace_end.html index c4c41e4ba..4eab4d1d7 100644 --- a/netbox/templates/dcim/inc/cable_trace_end.html +++ b/netbox/templates/dcim/inc/cable_trace_end.html @@ -20,7 +20,7 @@ {% with model=end|model_name %} {{ model|bettertitle }} {{ end }}
{% if model == 'interface' %} - {{ end.get_form_factor_display }} + {{ end.get_type_display }} {% elif model == 'front port' or model == 'rear port' %} {{ end.get_type_display }} {% endif %} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index c2d547cc0..e75e09076 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -4,6 +4,12 @@ {{ cp }} + + + {# Description #} + + {{ cp.description }} + {# Cable #} @@ -33,9 +39,16 @@ {% if cp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_consoleport %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 5442795cc..d1dce00d3 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -1,3 +1,5 @@ +{% load helpers %} + {# Checkbox #} @@ -12,12 +14,17 @@ {{ csp }} + {# Description #} + + {{ csp.description|placeholder }} + + {# Cable #} {% if csp.cable %} {{ csp.cable }} {% else %} - — + {% endif %} @@ -40,9 +47,16 @@ {% if csp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_consoleserverport %} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index 38f69f5a0..70ce7e8df 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -1,16 +1,35 @@ +{% load helpers %} + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} {% endif %} + + {# Name #} {{ devicebay.name }} + + {# Status #} + + {% if devicebay.installed_device %} + + {{ devicebay.installed_device.get_status_display }} + + {% else %} + Vacant + {% endif %} + + + {# Description #} + + {{ devicebay.description|placeholder }} + + + {# Installed device #} {% if devicebay.installed_device %} - - {{ devicebay.installed_device.get_status_display }} - {{ devicebay.installed_device }} @@ -18,11 +37,9 @@ {{ devicebay.installed_device.device_type.display_name }} {% else %} - - - Vacant - + {% endif %} + {% if perms.dcim.change_devicebay %} {% if devicebay.installed_device %} diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html index 2b468725d..ae86a36b4 100644 --- a/netbox/templates/dcim/inc/frontport.html +++ b/netbox/templates/dcim/inc/frontport.html @@ -58,9 +58,17 @@ {% if frontport.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_frontport %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 584d1754d..fec69c875 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -10,7 +10,7 @@ {# Icon and name #} - + {{ iface }} @@ -81,7 +81,7 @@ - + {{ iface.connected_endpoint }} @@ -151,9 +151,17 @@ {% if iface.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %} {% elif iface.is_connectable and perms.dcim.add_cable %} - - - + + + + {% endif %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 5aaeaef2e..512f75a61 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -1,3 +1,5 @@ +{% load helpers %} + {# Checkbox #} @@ -12,25 +14,50 @@ {{ po }} + {# Input/leg #} + + {% if po.power_port %} + {{ po.power_port }}{% if po.feed_leg %} / {{ po.get_feed_leg_display }}{% endif %} + {% elif po.feed_leg %} + {{ po.get_feed_leg_display }} + {% else %} + None + {% endif %} + + + {# Description #} + + {{ po.description|placeholder }} + + {# Cable #} {% if po.cable %} {{ po.cable }} {% else %} - — + {% endif %} {# Connection #} {% if po.connected_endpoint %} - - {{ po.connected_endpoint.device }} - - - {{ po.connected_endpoint }} - + {% with pp=po.connected_endpoint %} + + {{ pp.device }} + + + {{ pp }} + + + {% if pp.allocated_draw %} + {{ pp.allocated_draw }}W{% if pp.maximum_draw %} ({{ pp.maximum_draw }}W max){% endif %} + {% elif pp.maximum_draw %} + {{ pp.maximum_draw }}W + {% endif %} + + {% endwith %} {% else %} - + Not connected {% endif %} @@ -40,7 +67,7 @@ {% if po.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %} {% elif perms.dcim.add_cable %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 0c7bb1cdc..e8cd77857 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -5,6 +5,20 @@ {{ pp }} + {# Current draw #} + + {% if pp.allocated_draw %} + {{ pp.allocated_draw }}W{% if pp.maximum_draw %} ({{ pp.maximum_draw }}W max){% endif %} + {% elif pp.maximum_draw %} + {{ pp.maximum_draw }}W + {% endif %} + + + {# Description #} + + {{ pp.description }} + + {# Cable #} {% if pp.cable %} @@ -15,13 +29,17 @@ {# Connection #} - {% if pp.connected_endpoint %} + {% if pp.connected_endpoint.device %} {{ pp.connected_endpoint.device }} {{ pp.connected_endpoint }} + {% elif pp.connected_endpoint %} + + {{ pp.connected_endpoint }} + {% else %} Not connected @@ -33,9 +51,15 @@ {% if pp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_powerport %} diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html index 63aae1127..27609e726 100644 --- a/netbox/templates/dcim/inc/rearport.html +++ b/netbox/templates/dcim/inc/rearport.html @@ -57,9 +57,17 @@ {% if rearport.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=rearport.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_rearport %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3c3951417..4e7cc6306 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -32,9 +32,11 @@ - + {% if perms.extras.view_objectchange %} + + {% endif %} {% endblock %} @@ -58,7 +60,7 @@ Type - {{ interface.get_form_factor_display }} + {{ interface.get_type_display }} Enabled @@ -123,7 +125,7 @@ Type - {{ connected_interface.get_form_factor_display }} + {{ connected_interface.get_type_display }} Enabled @@ -225,7 +227,7 @@ {{ member }} - {{ member.get_form_factor_display }} + {{ member.get_type_display }} {% empty %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 6acdf39e3..fedb1b61a 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -6,7 +6,7 @@
Interface
{% render_field form.name %} - {% render_field form.form_factor %} + {% render_field form.type %} {% render_field form.enabled %} {% render_field form.lag %} {% render_field form.mac_address %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html new file mode 100644 index 000000000..8589524c9 --- /dev/null +++ b/netbox/templates/dcim/powerfeed.html @@ -0,0 +1,155 @@ +{% extends '_base.html' %} +{% load static %} +{% load custom_links %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.dcim.change_powerfeed %} + + + Edit this power feed + + {% endif %} + {% if perms.dcim.delete_powerfeed %} + + + Delete this power feed + + {% endif %} +
+

{% block title %}{{ powerfeed }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=powerfeed %} +
+ {% custom_links powerfeed %} +
+ +{% endblock %} + +{% block content %} +
+
+
+
+ Power Feed +
+ + + + + + + + + + + + + + + + + + + + + + + + {% with utilization=powerfeed.connected_endpoint.get_power_draw %} + {% if utilization %} + + {% else %} + + {% endif %} + {% endwith %} + +
Power Panel + {{ powerfeed.power_panel }} +
Rack + {% if powerfeed.rack %} + {{ powerfeed.rack }} + {% else %} + None + {% endif %} +
Type + {{ powerfeed.get_type_display }} +
Status + {{ powerfeed.get_status_display }} +
Connected Device + {% if powerfeed.connected_endpoint %} + {{ powerfeed.connected_endpoint.device }} ({{ powerfeed.connected_endpoint }}) + {% else %} + None + {% endif %} +
Utilization (Allocated) + {{ utilization.allocated }}VA / {{ powerfeed.available_power }}VA + {% utilization_graph utilization.allocated|percentage:powerfeed.available_power %} + N/A
+
+
+
+
+
+ Electrical Characteristics +
+ + + + + + + + + + + + + + + + + + + + + +
Supply{{ powerfeed.get_supply_display }}
Voltage{{ powerfeed.voltage }}V
Amperage{{ powerfeed.amperage }}A
Phase{{ powerfeed.get_phase_display }}
Max Utilization{{ powerfeed.max_utilization }}%
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html new file mode 100644 index 000000000..f4b3ada46 --- /dev/null +++ b/netbox/templates/dcim/powerfeed_edit.html @@ -0,0 +1,46 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Power Feed
+
+ {% render_field form.site %} + {% render_field form.power_panel %} + {% render_field form.rack %} + {% render_field form.name %} + {% render_field form.status %} +
+
+
+
Characteristics
+
+ {% render_field form.type %} + {% render_field form.supply %} + {% render_field form.voltage %} + {% render_field form.amperage %} + {% render_field form.phase %} + {% render_field form.max_utilization %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +
+
Tags
+
+ {% render_field form.tags %} +
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerfeed_list.html b/netbox/templates/dcim/powerfeed_list.html new file mode 100644 index 000000000..cfe2c989c --- /dev/null +++ b/netbox/templates/dcim/powerfeed_list.html @@ -0,0 +1,22 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% if perms.dcim.add_powerfeed %} + {% add_button 'dcim:powerfeed_add' %} + {% import_button 'dcim:powerfeed_import' %} + {% endif %} + {% export_button content_type %} +
+

{% block title %}Power Feeds{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html new file mode 100644 index 000000000..b92eddfc7 --- /dev/null +++ b/netbox/templates/dcim/powerpanel.html @@ -0,0 +1,93 @@ +{% extends '_base.html' %} +{% load static %} +{% load custom_links %} +{% load helpers %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.dcim.change_powerpanel %} + + + Edit this power panel + + {% endif %} + {% if perms.dcim.delete_powerpanel %} + + + Delete this power panel + + {% endif %} +
+

{% block title %}{{ powerpanel }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=powerpanel %} +
+ {% custom_links powerpanel %} +
+ +{% endblock %} + +{% block content %} +
+
+
+
+ Power Panel +
+ + + + + + + + + +
Site + {{ powerpanel.site }} +
Rack Group + {% if powerpanel.rack_group %} + {{ powerpanel.rack_group }} + {% else %} + None + {% endif %} +
+
+
+
+ {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerpanel_list.html b/netbox/templates/dcim/powerpanel_list.html new file mode 100644 index 000000000..a0d49b30b --- /dev/null +++ b/netbox/templates/dcim/powerpanel_list.html @@ -0,0 +1,21 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% if perms.dcim.add_powerpanel %} + {% add_button 'dcim:powerpanel_add' %} + {% import_button 'dcim:powerpanel_import' %} + {% endif %} + {% export_button content_type %} +
+

{% block title %}Power Panels{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 68ea75b6c..60a70c36c 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -43,13 +44,18 @@

{% block title %}Rack {{ rack }}{% endblock %}

{% include 'inc/created_updated.html' with obj=rack %} +
+ {% custom_links rack %} +
{% endblock %} @@ -190,47 +196,45 @@ {% endif %}
-
-
- Non-Racked Devices -
- {% if nonracked_devices %} - + {% if power_feeds %} +
+
+ Power Feeds +
+
- - + + + - + - {% for device in nonracked_devices %} - + {% for powerfeed in power_feeds %} + - - + + {% with power_port=powerfeed.connected_endpoint %} + {% if power_port %} + {% else %} - + {% endif %} - + {% endwith %} {% endfor %}
NameRolePanelFeedStatus TypeParentUtilization
- {{ device }} + {{ powerfeed.power_panel.name }} + + + {{ powerfeed.name }} {{ device.device_role }}{{ device.device_type.display_name }} - {% if device.parent_bay %} - {{ device.parent_bay }} + {{ powerfeed.get_status_display }} + + {{ powerfeed.get_type_display }} + {% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}N/A
- {% else %} -
None
- {% endif %} - {% if perms.dcim.add_device %} - - {% endif %} -
+
+ {% endif %}
Images @@ -299,19 +303,62 @@ {% endif %}
-
-
-
-

Front

-
- {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} -
-
-
-

Rear

+
+
+
+
+

Front

+
+ {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} +
+
+
+

Rear

+
+ {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %} +
+
+
+
+ Non-Racked Devices +
+ {% if nonracked_devices %} + + + + + + + + {% for device in nonracked_devices %} + + + + + + + {% endfor %} +
NameRoleTypeParent
+ {{ device }} + {{ device.device_role }}{{ device.device_type.display_name }} + {% if device.parent_bay %} + {{ device.parent_bay }} + {% else %} + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% if perms.dcim.add_device %} + + {% endif %}
- {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %} -
{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 0ef14ee1b..0e38d2967 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,6 +1,7 @@ {% extends '_base.html' %} {% load static %} {% load tz %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -52,13 +53,18 @@

{% block title %}{{ site }}{% endblock %}

{% include 'inc/created_updated.html' with obj=site %} +
+ {% custom_links site %} +
{% endblock %} diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 5251b74de..3011e2d3b 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -37,6 +37,17 @@ {% endif %}

{% block title %}Tag: {{ tag }}{% endblock %}

+ {% include 'inc/created_updated.html' with obj=tag %} + {% endblock %} {% block content %} @@ -65,8 +76,26 @@ {{ items_count }} + + Color + +   + + +
+
+ Comments +
+
+ {% if tag.comments %} + {{ tag.comments|gfm }} + {% else %} + None + {% endif %} +
+
{% include 'panel_table.html' with table=items_table heading='Tagged Objects' %} diff --git a/netbox/templates/extras/tag_edit.html b/netbox/templates/extras/tag_edit.html new file mode 100644 index 000000000..800db1d26 --- /dev/null +++ b/netbox/templates/extras/tag_edit.html @@ -0,0 +1,19 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Tag
+
+ {% render_field form.name %} + {% render_field form.slug %} + {% render_field form.color %} +
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+{% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 76d90bad7..bc00d4a28 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -11,15 +11,25 @@
- {{ stats.site_count }} -

Sites

+ {% if perms.dcim.view_site %} + {{ stats.site_count }} +

Sites

+ {% else %} + +

Sites

+ {% endif %}

Geographic locations

- {{ stats.tenant_count }} -

Tenants

-

Customers or departments

-
+ {% if perms.tenancy.view_tenant %} + {{ stats.tenant_count }} +

Tenants

+ {% else %} + +

Tenants

+ {% endif %} +

Customers or departments

+
@@ -28,42 +38,99 @@
- {{ stats.rack_count }} -

Racks

+ {% if perms.dcim.view_rack %} + {{ stats.rack_count }} +

Racks

+ {% else %} + +

Racks

+ {% endif %}

Equipment racks, optionally organized by group

- {{ stats.device_count }} -

Devices

+ {% if perms.dcim.view_devicetype %} + {{ stats.devicetype_count }} +

Device Types

+ {% else %} + +

Device Types

+ {% endif %} +

Physical hardware models by manufacturer

+
+
+ {% if perms.dcim.view_device %} + {{ stats.device_count }} +

Devices

+ {% else %} + +

Devices

+ {% endif %}

Rack-mounted network equipment, servers, and other devices

Connections

- {{ stats.cable_count }} -

Cables

- {{ stats.interface_connections_count }} -

Interfaces

- {{ stats.console_connections_count }} -

Console

- {{ stats.power_connections_count }} -

Power

+ {% if perms.dcim.view_cable %} + {{ stats.cable_count }} +

Cables

+ {% else %} + +

Cables

+ {% endif %} + {% if perms.dcim.view_interface %} + {{ stats.interface_connections_count }} +

Interfaces

+ {% else %} + +

Interfaces

+ {% endif %} + {% if perms.dcim.view_consoleport and perms.dcim.view_consoleserverport %} + {{ stats.console_connections_count }} +

Console

+ {% else %} + +

Console

+ {% endif %} + {% if perms.dcim.view_powerport and perms.dcim.view_poweroutlet %} + {{ stats.power_connections_count }} +

Power

+ {% else %} + +

Power

+ {% endif %}
- Virtualization + Power
- {{ stats.cluster_count }} -

Clusters

-

Clusters of physical hosts in which VMs reside

+ {% if perms.dcim.view_powerfeed %} + {{ stats.powerfeed_count }} +

Power Feeds

+ {% else %} + +

Power Feeds

+ {% endif %} +

Electrical circuits delivering power from panels

+
+
+
+
+ Secrets +
+
- {{ stats.virtualmachine_count }} -

Virtual Machines

-

Virtual compute instances running inside clusters

+ {% if perms.secrets.view_secret %} + {{ stats.secret_count }} +

Secrets

+ {% else %} + +

Secrets

+ {% endif %} +

Cryptographically secured secret data

@@ -75,28 +142,53 @@
- {{ stats.vrf_count }} -

VRFs

+ {% if perms.ipam.view_vrf %} + {{ stats.vrf_count }} +

VRFs

+ {% else %} + +

VRFs

+ {% endif %}

Virtual routing and forwarding tables

- {{ stats.aggregate_count }} -

Aggregates

+ {% if perms.ipam.view_aggregate %} + {{ stats.aggregate_count }} +

Aggregates

+ {% else %} + +

Aggregates

+ {% endif %}

Top-level IP allocations

- {{ stats.prefix_count }} -

Prefixes

+ {% if perms.ipam.view_prefix %} + {{ stats.prefix_count }} +

Prefixes

+ {% else %} + +

Prefixes

+ {% endif %}

IPv4 and IPv6 network assignments

- {{ stats.ipaddress_count }} -

IP Addresses

+ {% if perms.ipam.view_ipaddress %} + {{ stats.ipaddress_count }} +

IP Addresses

+ {% else %} + +

IP Addresses

+ {% endif %}

Individual IPv4 and IPv6 addresses

- {{ stats.vlan_count }} -

VLANs

+ {% if perms.ipam.view_vlan %} + {{ stats.vlan_count }} +

VLANs

+ {% else %} + +

VLANs

+ {% endif %}

Layer two domains, identified by VLAN ID

@@ -107,38 +199,61 @@
- {{ stats.provider_count }} -

Providers

+ {% if perms.circuits.view_provider %} + {{ stats.provider_count }} +

Providers

+ {% else %} + +

Providers

+ {% endif %}

Organizations which provide circuit connectivity

- {{ stats.circuit_count }} -

Circuits

+ {% if perms.circuits.view_circuit %} + {{ stats.circuit_count }} +

Circuits

+ {% else %} + +

Circuits

+ {% endif %}

Communication links for Internet transit, peering, and other services

- {% if perms.secrets %} -
-
- Secrets +
+
+ Virtualization +
+
+
+ {% if perms.virtualization.view_cluster %} + {{ stats.cluster_count }} +

Clusters

+ {% else %} + +

Clusters

+ {% endif %} +

Clusters of physical hosts in which VMs reside

-
-
- {{ stats.secret_count }} -

Secrets

-

Sensitive data (such as passwords) which has been stored securely

-
+
+ {% if perms.virtualization.view_virtualmachine %} + {{ stats.virtualmachine_count }} +

Virtual Machines

+ {% else %} + +

Virtual Machines

+ {% endif %} +

Virtual compute instances running inside clusters

- {% endif %} +
Global Topology Maps
- {% if topology_maps %} + {% if topology_maps and perms.extras.view_topologymap %} {% for tm in topology_maps %} @@ -147,17 +262,21 @@ {% endfor %}
+ {% elif perms.extras.view_topologymap %} +
+ None found +
{% else %}
- None + No permission
{% endif %}
- {% if report_results %} -
-
- Reports -
+
+
+ Reports +
+ {% if report_results and perms.extras.view_reportresult %} {% for result in report_results %} @@ -166,45 +285,61 @@ {% endfor %}
-
- {% endif %} + {% elif perms.extras.view_reportresult %} +
+ None found +
+ {% else %} +
+ No permission +
+ {% endif %} +
Changelog
-
- {% for change in changelog %} - {% with action=change.get_action_display|lower %} -
- {% if action == 'created' %} - - {% elif action == 'updated' %} - - {% elif action == 'deleted' %} - - {% endif %} - {{ change.changed_object_type.name|bettertitle }} - {% if change.changed_object.get_absolute_url %} - {{ change.changed_object }} - {% else %} - {{ change.changed_object|default:change.object_repr }} - {% endif %} -
- - {{ change.user|default:change.user_name }} - - {{ change.time|date:'SHORT_DATETIME_FORMAT' }} - -
- {% endwith %} - {% if forloop.last %} - - {% endif %} - {% empty %} -
No change history found
- {% endfor %} -
+ {% if changelog and perms.extras.view_objectchange %} +
+ {% for change in changelog %} + {% with action=change.get_action_display|lower %} +
+ {% if action == 'created' %} + + {% elif action == 'updated' %} + + {% elif action == 'deleted' %} + + {% endif %} + {{ change.changed_object_type.name|bettertitle }} + {% if change.changed_object.get_absolute_url %} + {{ change.changed_object }} + {% else %} + {{ change.changed_object|default:change.object_repr }} + {% endif %} +
+ + {{ change.user|default:change.user_name }} - + {{ change.time|date:'SHORT_DATETIME_FORMAT' }} + +
+ {% endwith %} + {% if forloop.last %} + + {% endif %} + {% endfor %} +
+ {% elif perms.extras.view_objectchange %} +
+ No change history found +
+ {% else %} +
+ No permission +
+ {% endif %}
diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 177e5df58..b8e0d6dcb 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -20,7 +20,7 @@ @@ -78,7 +78,7 @@ @@ -117,7 +117,7 @@ @@ -202,7 +202,7 @@ @@ -293,7 +293,7 @@ {% endif %}

{% block title %}{{ aggregate }}{% endblock %}

{% include 'inc/created_updated.html' with obj=aggregate %} +
+ {% custom_links aggregate %} +
{% endblock %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index c13653e3c..cb04e14d5 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -41,13 +42,18 @@

{% block title %}{{ ipaddress }}{% endblock %}

{% include 'inc/created_updated.html' with obj=ipaddress %} +
+ {% custom_links ipaddress %} +
{% endblock %} @@ -103,6 +109,10 @@ {% endif %} + + DNS Name + {{ ipaddress.dns_name|placeholder }} + Description {{ ipaddress.description|placeholder }} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 771999bb4..c24c94c87 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -17,6 +17,7 @@ {% render_field form.status %} {% render_field form.role %} {% render_field form.vrf %} + {% render_field form.dns_name %} {% render_field form.description %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 281515278..9ea34804e 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -52,6 +53,9 @@

{% block title %}{{ prefix }}{% endblock %}

{% include 'inc/created_updated.html' with obj=prefix %} +
+ {% custom_links prefix %} +
{% endblock %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 4d309da0c..f39bace84 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block content %} @@ -33,6 +34,9 @@ {% endif %}

{% block title %}{{ service }}{% endblock %}

{% include 'inc/created_updated.html' with obj=service %} +
+ {% custom_links service %} +
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index aa5869e5e..20e9f39e9 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -44,6 +45,9 @@

{% block title %}VLAN {{ vlan.display_name }}{% endblock %}

{% include 'inc/created_updated.html' with obj=vlan %} +
+ {% custom_links vlan %} +
{% endblock %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index fb81d7ee1..6c3ce013b 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -38,13 +39,18 @@

{% block title %}VRF {{ vrf }}{% endblock %}

{% include 'inc/created_updated.html' with obj=vrf %} +
+ {% custom_links vrf %} +
{% endblock %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 2825e6770..a52f626d3 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -1,5 +1,6 @@ {% extends '_base.html' %} {% load static %} +{% load custom_links %} {% load helpers %} {% load secret_helpers %} @@ -29,13 +30,18 @@

{% block title %}{{ secret }}{% endblock %}

{% include 'inc/created_updated.html' with obj=secret %} +
+ {% custom_links secret %} +
{% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 2cb925303..053c69121 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -41,13 +42,18 @@

{% block title %}{{ tenant }}{% endblock %}

{% include 'inc/created_updated.html' with obj=tenant %} +
+ {% custom_links tenant %} +
{% endblock %} diff --git a/netbox/templates/utilities/templatetags/tag.html b/netbox/templates/utilities/templatetags/tag.html index 09b885d42..0be4e7ca5 100644 --- a/netbox/templates/utilities/templatetags/tag.html +++ b/netbox/templates/utilities/templatetags/tag.html @@ -1,5 +1,7 @@ +{% load helpers %} + {% if url_name %} - {{ tag }} + {{ tag }} {% else %} {{ tag }} {% endif %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index d6a589775..f911b4faa 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -41,13 +42,18 @@

{% block title %}{{ cluster }}{% endblock %}

{% include 'inc/created_updated.html' with obj=cluster %} +
+ {% custom_links cluster %} +
{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index b1a8a1505..2498039ff 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -40,16 +41,23 @@

{% block title %}{{ virtualmachine }}{% endblock %}

{% include 'inc/created_updated.html' with obj=virtualmachine %} +
+ {% custom_links virtualmachine %} +
{% endblock %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index d26ac4675..80780dba3 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -15,10 +15,11 @@ __all__ = [ class NestedTenantGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') + tenant_count = serializers.IntegerField(read_only=True) class Meta: model = TenantGroup - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'tenant_count'] class NestedTenantSerializer(WritableNestedSerializer): diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 80f3b948d..28ae04694 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,3 +1,4 @@ +from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from extras.api.customfields import CustomFieldModelSerializer @@ -11,19 +12,30 @@ from .nested_serializers import * # class TenantGroupSerializer(ValidatedModelSerializer): + tenant_count = serializers.IntegerField(read_only=True) class Meta: model = TenantGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'tenant_count'] class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): group = NestedTenantGroupSerializer(required=False) tags = TagListSerializerField(required=False) + circuit_count = serializers.IntegerField(read_only=True) + device_count = serializers.IntegerField(read_only=True) + ipaddress_count = serializers.IntegerField(read_only=True) + prefix_count = serializers.IntegerField(read_only=True) + rack_count = serializers.IntegerField(read_only=True) + site_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) + vlan_count = serializers.IntegerField(read_only=True) + vrf_count = serializers.IntegerField(read_only=True) class Meta: model = Tenant fields = [ 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', + 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index af3e318fc..b79a076f1 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,7 +1,12 @@ +from circuits.models import Circuit +from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet +from ipam.models import IPAddress, Prefix, VLAN, VRF from tenancy import filters from tenancy.models import Tenant, TenantGroup from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.utils import get_subquery +from virtualization.models import VirtualMachine from . import serializers @@ -18,7 +23,9 @@ class TenancyFieldChoicesViewSet(FieldChoicesViewSet): # class TenantGroupViewSet(ModelViewSet): - queryset = TenantGroup.objects.all() + queryset = TenantGroup.objects.annotate( + tenant_count=get_subquery(Tenant, 'group') + ) serializer_class = serializers.TenantGroupSerializer filterset_class = filters.TenantGroupFilter @@ -28,6 +35,20 @@ class TenantGroupViewSet(ModelViewSet): # class TenantViewSet(CustomFieldModelViewSet): - queryset = Tenant.objects.select_related('group').prefetch_related('tags') + queryset = Tenant.objects.select_related( + 'group' + ).prefetch_related( + 'tags' + ).annotate( + circuit_count=get_subquery(Circuit, 'tenant'), + device_count=get_subquery(Device, 'tenant'), + ipaddress_count=get_subquery(IPAddress, 'tenant'), + prefix_count=get_subquery(Prefix, 'tenant'), + rack_count=get_subquery(Rack, 'tenant'), + site_count=get_subquery(Site, 'tenant'), + virtualmachine_count=get_subquery(VirtualMachine, 'tenant'), + vlan_count=get_subquery(VLAN, 'tenant'), + vrf_count=get_subquery(VRF, 'tenant') + ) serializer_class = serializers.TenantSerializer filterset_class = filters.TenantFilter diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 2610b3ec0..acb0fa0cc 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -10,10 +10,10 @@ class TenantGroupFilter(NameSlugSearchFilterSet): class Meta: model = TenantGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): +class TenantFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/tenancy/migrations/0006_custom_tag_models.py b/netbox/tenancy/migrations/0006_custom_tag_models.py new file mode 100644 index 000000000..dd39ff471 --- /dev/null +++ b/netbox/tenancy/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 = [ + ('tenancy', '0005_change_logging'), + ('extras', '0019_tag_taggeditem'), + ] + + operations = [ + migrations.AlterField( + model_name='tenant', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 045679b90..bc67804d6 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -3,7 +3,7 @@ from django.db import models from django.urls import reverse from taggit.managers import TaggableManager -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, TaggedItem from utilities.models import ChangeLoggedModel @@ -70,7 +70,7 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['name', 'slug', 'group', 'description', 'comments'] diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 69db73ac6..121898019 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -36,7 +36,7 @@ class TenantGroupTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'slug', 'tenant_count', 'url'] ) def test_create_tenantgroup(self): diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index a6ca8f358..877b70145 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -4,13 +4,15 @@ from django.test import Client, TestCase from django.urls import reverse from tenancy.models import Tenant, TenantGroup +from utilities.testing import create_test_user class TenantGroupTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['tenancy.view_tenantgroup']) self.client = Client() + self.client.force_login(user) TenantGroup.objects.bulk_create([ TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), @@ -29,8 +31,9 @@ class TenantGroupTestCase(TestCase): class TenantTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['tenancy.view_tenant']) self.client = Client() + self.client.force_login(user) tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1') tenantgroup.save() diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 97334c9f0..5d43309de 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -18,7 +18,8 @@ from .models import Tenant, TenantGroup # Tenant groups # -class TenantGroupListView(ObjectListView): +class TenantGroupListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'tenancy.view_tenantgroup' queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable template_name = 'tenancy/tenantgroup_list.html' @@ -53,7 +54,8 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Tenants # -class TenantListView(ObjectListView): +class TenantListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'tenancy.view_tenant' queryset = Tenant.objects.select_related('group') filter = filters.TenantFilter filter_form = forms.TenantFilterForm @@ -61,7 +63,8 @@ class TenantListView(ObjectListView): template_name = 'tenancy/tenant_list.html' -class TenantView(View): +class TenantView(PermissionRequiredMixin, View): + permission_required = 'tenancy.view_tenant' def get(self, request, slug): diff --git a/netbox/users/views.py b/netbox/users/views.py index 0ff4a8049..6abdd817d 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,7 +1,6 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash -from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in @@ -82,8 +81,7 @@ class LogoutView(View): # User profiles # -@method_decorator(login_required, name='dispatch') -class ProfileView(View): +class ProfileView(LoginRequiredMixin, View): template_name = 'users/profile.html' def get(self, request): @@ -93,8 +91,7 @@ class ProfileView(View): }) -@method_decorator(login_required, name='dispatch') -class ChangePasswordView(View): +class ChangePasswordView(LoginRequiredMixin, View): template_name = 'users/change_password.html' def get(self, request): @@ -119,8 +116,7 @@ class ChangePasswordView(View): }) -@method_decorator(login_required, name='dispatch') -class UserKeyView(View): +class UserKeyView(LoginRequiredMixin, View): template_name = 'users/userkey.html' def get(self, request): @@ -135,10 +131,9 @@ class UserKeyView(View): }) -class UserKeyEditView(View): +class UserKeyEditView(LoginRequiredMixin, View): template_name = 'users/userkey_edit.html' - @method_decorator(login_required) def dispatch(self, request, *args, **kwargs): try: self.userkey = UserKey.objects.get(user=request.user) @@ -172,7 +167,6 @@ class UserKeyEditView(View): }) -@method_decorator(login_required, name='dispatch') class SessionKeyDeleteView(LoginRequiredMixin, View): def get(self, request): diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 74108fbc9..9354cbb30 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -3,7 +3,7 @@ from collections import OrderedDict import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist from django.db.models import ManyToManyField, ProtectedError from django.http import Http404 from rest_framework.exceptions import APIException @@ -13,7 +13,7 @@ from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet -from .utils import dynamic_import +from .utils import dict_to_filter_params, dynamic_import class ServiceUnavailable(APIException): @@ -200,22 +200,48 @@ class WritableNestedSerializer(ModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. """ - def run_validators(self, value): - # DRF v3.8.2: Skip running validators on the data, since we only accept an integer PK instead of a dict. For - # more context, see: - # https://github.com/encode/django-rest-framework/pull/5922/commits/2227bc47f8b287b66775948ffb60b2d9378ac84f - # https://github.com/encode/django-rest-framework/issues/6053 - return def to_internal_value(self, data): + if data is None: return None + + # Dictionary of related object attributes + if isinstance(data, dict): + params = dict_to_filter_params(data) + try: + return self.Meta.model.objects.get(**params) + except ObjectDoesNotExist: + raise ValidationError( + "Related object not found using the provided attributes: {}".format(params) + ) + except MultipleObjectsReturned: + raise ValidationError( + "Multiple objects match the provided attributes: {}".format(params) + ) + except FieldError as e: + raise ValidationError(e) + + # Integer PK of related object + if isinstance(data, int): + pk = data + else: + try: + # PK might have been mistakenly passed as a string + pk = int(data) + except (TypeError, ValueError): + raise ValidationError( + "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " + "unrecognized value: {}".format(data) + ) + + # Look up object by PK try: return self.Meta.model.objects.get(pk=int(data)) - except (TypeError, ValueError): - raise ValidationError("Primary key must be an integer") except ObjectDoesNotExist: - raise ValidationError("Invalid ID") + raise ValidationError( + "Related object not found using the provided numeric ID: {}".format(pk) + ) # @@ -261,6 +287,18 @@ class ModelViewSet(_ModelViewSet): **kwargs ) + def list(self, *args, **kwargs): + """ + Call to super to allow for caching + """ + return super().list(*args, **kwargs) + + def retrieve(self, *args, **kwargs): + """ + Call to super to allow for caching + """ + return super().retrieve(*args, **kwargs) + class FieldChoicesViewSet(ViewSet): """ diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py new file mode 100644 index 000000000..54541b0b5 --- /dev/null +++ b/netbox/utilities/auth_backends.py @@ -0,0 +1,28 @@ +from django.conf import settings +from django.contrib.auth.backends import ModelBackend + + +class ViewExemptModelBackend(ModelBackend): + """ + Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view + permission enforcement. + """ + def has_perm(self, user_obj, perm, obj=None): + + # If this is a view permission, check whether the model has been exempted from enforcement + try: + app, codename = perm.split('.') + action, model = codename.split('_') + if action == 'view': + if ( + # All models are exempt from view permission enforcement + '*' in settings.EXEMPT_VIEW_PERMISSIONS + ) or ( + # This specific model is exempt from view permission enforcement + '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS + ): + return True + except ValueError: + pass + + return super().has_perm(user_obj, perm, obj) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index b0c2b3ec3..614c09902 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,7 +1,49 @@ import django_filters +from django import forms from django.conf import settings -from django.db.models import Q -from taggit.models import Tag +from django.db import models + +from extras.models import Tag + + +def multivalue_field_factory(field_class): + """ + Given a form field class, return a subclass capable of accepting multiple values. This allows us to OR on multiple + filter values while maintaining the field's built-in vlaidation. Example: GET /api/dcim/devices/?name=foo&name=bar + """ + class NewField(field_class): + widget = forms.SelectMultiple + + def to_python(self, value): + if not value: + return [] + return [super(field_class, self).to_python(v) for v in value] + + return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) + + +# +# Filters +# + +class MultiValueCharFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.CharField) + + +class MultiValueDateFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DateField) + + +class MultiValueDateTimeFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DateTimeField) + + +class MultiValueNumberFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.IntegerField) + + +class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.TimeField) class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): @@ -47,6 +89,10 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): super().__init__(*args, **kwargs) +# +# FilterSets +# + class NameSlugSearchFilterSet(django_filters.FilterSet): """ A base class for adding the search method to models which only expose the `name` and `slug` fields @@ -60,6 +106,57 @@ class NameSlugSearchFilterSet(django_filters.FilterSet): if not value.strip(): return queryset return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) + models.Q(name__icontains=value) | + models.Q(slug__icontains=value) ) + + +# +# Update default filters +# + +FILTER_DEFAULTS = django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS +FILTER_DEFAULTS.update({ + models.AutoField: { + 'filter_class': MultiValueNumberFilter + }, + models.CharField: { + 'filter_class': MultiValueCharFilter + }, + models.DateField: { + 'filter_class': MultiValueDateFilter + }, + models.DateTimeField: { + 'filter_class': MultiValueDateTimeFilter + }, + models.DecimalField: { + 'filter_class': MultiValueNumberFilter + }, + models.EmailField: { + 'filter_class': MultiValueCharFilter + }, + models.FloatField: { + 'filter_class': MultiValueNumberFilter + }, + models.IntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveSmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.SlugField: { + 'filter_class': MultiValueCharFilter + }, + models.SmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.TimeField: { + 'filter_class': MultiValueTimeFilter + }, + models.URLField: { + 'filter_class': MultiValueCharFilter + }, +}) diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index c70be72ed..360fb29fc 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -20,11 +20,15 @@ class LoginRequiredMiddleware(object): def __call__(self, request): if LOGIN_REQUIRED and not request.user.is_authenticated: # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API - # performs its own authentication. + # performs its own authentication. Also metrics can be read without login. api_path = reverse('api-root') - if not request.path_info.startswith(api_path) and request.path_info != settings.LOGIN_URL: - return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, - urllib.parse.quote(request.get_full_path_info()))) + if not request.path_info.startswith((api_path, '/metrics')) and request.path_info != settings.LOGIN_URL: + return HttpResponseRedirect( + '{}?next={}'.format( + settings.LOGIN_URL, + urllib.parse.quote(request.get_full_path_info()) + ) + ) return self.get_response(request) diff --git a/netbox/utilities/sql.py b/netbox/utilities/sql.py deleted file mode 100644 index d76bc339e..000000000 --- a/netbox/utilities/sql.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.db import connections, models -from django.db.models.sql.compiler import SQLCompiler - - -class NullsFirstSQLCompiler(SQLCompiler): - - def get_order_by(self): - result = super().get_order_by() - if result: - return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result] - return result - - -class NullsFirstQuery(models.sql.query.Query): - - def get_compiler(self, using=None, connection=None): - if using is None and connection is None: - raise ValueError("Need either using or connection") - if using: - connection = connections[using] - return NullsFirstSQLCompiler(self, connection, using) - - -class NullsFirstQuerySet(models.QuerySet): - """ - Override PostgreSQL's default behavior of ordering NULLs last. This is needed e.g. to order Prefixes in the global - table before those assigned to a VRF. - """ - - def __init__(self, model=None, query=None, using=None, hints=None): - super().__init__(model, query, using, hints) - self.query = query or NullsFirstQuery(self.model) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 2b465d54a..e6616d888 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -166,6 +166,26 @@ def fgcolor(value): return '#{}'.format(foreground_color(value)) +@register.filter() +def divide(x, y): + """ + Return x/y (rounded). + """ + if x is None or y is None: + return None + return round(x / y) + + +@register.filter() +def percentage(x, y): + """ + Return x/y as a percentage. + """ + if x is None or y is None: + return None + return round(x / y * 100) + + # # Tags # diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index 86fa8c836..0534a340b 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import Permission, User from rest_framework.test import APITestCase as _APITestCase from users.models import Token @@ -22,3 +22,16 @@ class APITestCase(_APITestCase): self.assertEqual(response.status_code, expected_status, err_message.format( expected_status, response.status_code, response.data )) + + +def create_test_user(username='testuser', permissions=list()): + """ + Create a User with the given permissions. + """ + user = User.objects.create_user(username=username) + for perm_name in permissions: + app, codename = perm_name.split('.') + perm = Permission.objects.get(content_type__app_label=app, codename=codename) + user.user_permissions.add(perm) + + return user diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py new file mode 100644 index 000000000..3ff4b3876 --- /dev/null +++ b/netbox/utilities/tests/test_api.py @@ -0,0 +1,119 @@ +from django.urls import reverse +from rest_framework import status + +from dcim.models import Region, Site +from ipam.models import VLAN +from utilities.testing import APITestCase + + +class WritableNestedSerializerTest(APITestCase): + """ + Test the operation of WritableNestedSerializer using VLANSerializer as our test subject. + """ + + def setUp(self): + + super().setUp() + + self.region_a = Region.objects.create(name='Region A', slug='region-a') + self.site1 = Site.objects.create(region=self.region_a, name='Site 1', slug='site-1') + self.site2 = Site.objects.create(region=self.region_a, name='Site 2', slug='site-2') + + def test_related_by_pk(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': self.site1.pk, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['site']['id'], self.site1.pk) + vlan = VLAN.objects.get(pk=response.data['id']) + self.assertEqual(vlan.site, self.site1) + + def test_related_by_pk_no_match(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': 999, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VLAN.objects.count(), 0) + self.assertTrue(response.data['site'][0].startswith("Related object not found")) + + def test_related_by_attributes(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': { + 'name': 'Site 1' + }, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['site']['id'], self.site1.pk) + vlan = VLAN.objects.get(pk=response.data['id']) + self.assertEqual(vlan.site, self.site1) + + def test_related_by_attributes_no_match(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': { + 'name': 'Site X' + }, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VLAN.objects.count(), 0) + self.assertTrue(response.data['site'][0].startswith("Related object not found")) + + def test_related_by_attributes_multiple_matches(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': { + 'region': { + "name": "Region A", + }, + }, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VLAN.objects.count(), 0) + self.assertTrue(response.data['site'][0].startswith("Multiple objects match")) + + def test_related_by_invalid(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': 'XXX', + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VLAN.objects.count(), 0) diff --git a/netbox/utilities/tests/test_utils.py b/netbox/utilities/tests/test_utils.py index 4e0fec1ba..5d9a98ad5 100644 --- a/netbox/utilities/tests/test_utils.py +++ b/netbox/utilities/tests/test_utils.py @@ -1,13 +1,48 @@ from django.test import TestCase -from utilities.utils import deepmerge +from utilities.utils import deepmerge, dict_to_filter_params + + +class DictToFilterParamsTest(TestCase): + """ + Validate the operation of dict_to_filter_params(). + """ + def setUp(self): + return + + def test_dict_to_filter_params(self): + + input = { + 'a': True, + 'foo': { + 'bar': 123, + 'baz': 456, + }, + 'x': { + 'y': { + 'z': False + } + } + } + + output = { + 'a': True, + 'foo__bar': 123, + 'foo__baz': 456, + 'x__y__z': False, + } + + self.assertEqual(dict_to_filter_params(input), output) + + input['x']['y']['z'] = True + + self.assertNotEqual(dict_to_filter_params(input), output) class DeepMergeTest(TestCase): """ Validate the behavior of the deepmerge() utility. """ - def setUp(self): return diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 1d1f12ddb..4644c432e 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -4,6 +4,7 @@ import datetime import json from django.core.serializers import serialize +from django.db.models import Count, OuterRef, Subquery from dcim.constants import LENGTH_UNIT_CENTIMETER, LENGTH_UNIT_FOOT, LENGTH_UNIT_INCH, LENGTH_UNIT_METER @@ -60,6 +61,34 @@ def dynamic_import(name): return mod +def model_names_to_filter_dict(names): + """ + Accept a list of content types in the format ['.', '.', ...] and return a dictionary + suitable for QuerySet filtering. + """ + # TODO: This should match on the app_label as well as the model name to avoid potential duplicate names + return { + 'model__in': [model.split('.')[1] for model in names], + } + + +def get_subquery(model, field): + """ + Return a Subquery suitable for annotating a child object count. + """ + subquery = Subquery( + model.objects.filter( + **{field: OuterRef('pk')} + ).order_by().values( + field + ).annotate( + c=Count('*') + ).values('c') + ) + + return subquery + + def serialize_object(obj, extra=None): """ Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like @@ -85,6 +114,38 @@ def serialize_object(obj, extra=None): return data +def dict_to_filter_params(d, prefix=''): + """ + Translate a dictionary of attributes to a nested set of parameters suitable for QuerySet filtering. For example: + + { + "name": "Foo", + "rack": { + "facility_id": "R101" + } + } + + Becomes: + + { + "name": "Foo", + "rack__facility_id": "R101" + } + + And can be employed as filter parameters: + + Device.objects.filter(**dict_to_filter(attrs_dict)) + """ + params = {} + for key, val in d.items(): + k = prefix + key + if isinstance(val, dict): + params.update(dict_to_filter_params(val, k + '__')) + else: + params[k] = val + return params + + def deepmerge(original, new): """ Deep merge two dictionaries (new into original) and return a new dict diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index fd54088ba..bbc58f134 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -143,7 +143,7 @@ class ObjectListView(View): # Construct queryset for tags list if hasattr(model, 'tags'): - tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('name') + tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name') else: tags = None diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index fb6e2b0be..47b7e6442 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -19,26 +19,29 @@ __all__ = [ class NestedClusterTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') + cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'cluster_count'] class NestedClusterGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') + cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'cluster_count'] class NestedClusterSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = Cluster - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'name', 'virtualmachine_count'] # diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 1b06dab3b..0b98ce44a 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,8 +1,9 @@ +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer -from dcim.constants import IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES +from dcim.constants import IFACE_TYPE_CHOICES, IFACE_TYPE_VIRTUAL, IFACE_MODE_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -19,17 +20,19 @@ from .nested_serializers import * # class ClusterTypeSerializer(ValidatedModelSerializer): + cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'cluster_count'] class ClusterGroupSerializer(ValidatedModelSerializer): + cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'cluster_count'] class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -37,11 +40,14 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): group = NestedClusterGroupSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) + device_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = Cluster fields = [ 'id', 'name', 'type', 'group', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'device_count', 'virtualmachine_count', ] @@ -80,6 +86,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): 'config_context', 'created', 'last_updated', ] + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_config_context(self, obj): return obj.get_config_context() @@ -90,7 +97,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False) + type = ChoiceField(choices=IFACE_TYPE_CHOICES, default=IFACE_TYPE_VIRTUAL, required=False) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -104,6 +111,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): class Meta: model = Interface fields = [ - 'id', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mtu', 'mac_address', 'description', 'mode', + 'id', 'virtual_machine', 'name', 'type', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 3b0c02b22..552d5e93f 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,6 +1,9 @@ -from dcim.models import Interface +from django.db.models import Count + +from dcim.models import Device, Interface from extras.api.views import CustomFieldModelViewSet from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.utils import get_subquery from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import serializers @@ -21,19 +24,30 @@ class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet): # class ClusterTypeViewSet(ModelViewSet): - queryset = ClusterType.objects.all() + queryset = ClusterType.objects.annotate( + cluster_count=Count('clusters') + ) serializer_class = serializers.ClusterTypeSerializer filterset_class = filters.ClusterTypeFilter class ClusterGroupViewSet(ModelViewSet): - queryset = ClusterGroup.objects.all() + queryset = ClusterGroup.objects.annotate( + cluster_count=Count('clusters') + ) serializer_class = serializers.ClusterGroupSerializer filterset_class = filters.ClusterGroupFilter class ClusterViewSet(CustomFieldModelViewSet): - queryset = Cluster.objects.select_related('type', 'group').prefetch_related('tags') + queryset = Cluster.objects.select_related( + 'type', 'group', 'site', + ).prefetch_related( + 'tags' + ).annotate( + device_count=get_subquery(Device, 'cluster'), + virtualmachine_count=get_subquery(VirtualMachine, 'cluster') + ) serializer_class = serializers.ClusterSerializer filterset_class = filters.ClusterFilter @@ -50,16 +64,23 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): def get_serializer_class(self): """ - Include rendered config context when retrieving a single VirtualMachine. + Select the specific serializer based on the request context. + + If the `brief` query param equates to True, return the NestedVirtualMachineSerializer + + If the `exclude` query param includes `config_context` as a value, return the VirtualMachineSerializer + + Else, return the VirtualMachineWithConfigContextSerializer """ - if self.action == 'retrieve': - return serializers.VirtualMachineWithConfigContextSerializer request = self.get_serializer_context()['request'] if request.query_params.get('brief', False): return serializers.NestedVirtualMachineSerializer - return serializers.VirtualMachineSerializer + elif 'config_context' in request.query_params.get('exclude', []): + return serializers.VirtualMachineSerializer + + return serializers.VirtualMachineWithConfigContextSerializer class InterfaceViewSet(ModelViewSet): diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index e71693ac6..9a6169815 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -15,14 +15,14 @@ class ClusterTypeFilter(NameSlugSearchFilterSet): class Meta: model = ClusterType - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class ClusterGroupFilter(NameSlugSearchFilterSet): class Meta: model = ClusterGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class ClusterFilter(CustomFieldFilterSet): @@ -164,7 +164,7 @@ class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet): class Meta: model = VirtualMachine - fields = ['name', 'cluster'] + fields = ['id', 'name', 'cluster', 'vcpus', 'memory', 'disk'] def search(self, queryset, name, value): if not value.strip(): @@ -198,7 +198,7 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name', 'enabled', 'mtu'] + fields = ['id', 'name', 'enabled', 'mtu'] def _mac_address(self, queryset, name, value): value = value.strip() diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 59b340e85..6ea00ee3e 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -2,7 +2,7 @@ from django import forms from django.core.exceptions import ValidationError from taggit.forms import TagField -from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL +from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm @@ -19,8 +19,8 @@ from utilities.forms import ( from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine -VIFACE_FF_CHOICES = ( - (IFACE_FF_VIRTUAL, 'Virtual'), +VIFACE_TYPE_CHOICES = ( + (IFACE_TYPE_VIRTUAL, 'Virtual'), ) @@ -620,12 +620,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ - 'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', + 'virtual_machine', 'name', 'type', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), - 'form_factor': forms.HiddenInput(), + 'type': forms.HiddenInput(), 'mode': StaticSelect2() } labels = { @@ -656,9 +656,9 @@ class InterfaceCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) - form_factor = forms.ChoiceField( - choices=VIFACE_FF_CHOICES, - initial=IFACE_FF_VIRTUAL, + type = forms.ChoiceField( + choices=VIFACE_TYPE_CHOICES, + initial=IFACE_TYPE_VIRTUAL, widget=forms.HiddenInput() ) enabled = forms.BooleanField( @@ -732,9 +732,9 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): - form_factor = forms.ChoiceField( - choices=VIFACE_FF_CHOICES, - initial=IFACE_FF_VIRTUAL, + type = forms.ChoiceField( + choices=VIFACE_TYPE_CHOICES, + initial=IFACE_TYPE_VIRTUAL, widget=forms.HiddenInput() ) enabled = forms.BooleanField( diff --git a/netbox/virtualization/migrations/0009_custom_tag_models.py b/netbox/virtualization/migrations/0009_custom_tag_models.py new file mode 100644 index 000000000..04d589bee --- /dev/null +++ b/netbox/virtualization/migrations/0009_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 = [ + ('virtualization', '0008_virtualmachine_local_context_data'), + ('extras', '0019_tag_taggeditem'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='virtualmachine', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index ff9f39ee9..2ef782dfd 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -6,7 +6,7 @@ from django.urls import reverse from taggit.managers import TaggableManager from dcim.models import Device -from extras.models import ConfigContextModel, CustomFieldModel +from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem from utilities.models import ChangeLoggedModel from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES @@ -119,7 +119,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['name', 'type', 'group', 'site', 'comments'] @@ -238,7 +238,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 328484a89..f1e372dd4 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,7 +2,7 @@ from django.urls import reverse from netaddr import IPNetwork from rest_framework import status -from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED +from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_TAGGED from dcim.models import Interface from ipam.models import IPAddress, VLAN from utilities.testing import APITestCase @@ -40,7 +40,7 @@ class ClusterTypeTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['cluster_count', 'id', 'name', 'slug', 'url'] ) def test_create_clustertype(self): @@ -141,7 +141,7 @@ class ClusterGroupTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['cluster_count', 'id', 'name', 'slug', 'url'] ) def test_create_clustergroup(self): @@ -245,7 +245,7 @@ class ClusterTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'url'] + ['id', 'name', 'url', 'virtualmachine_count'] ) def test_create_cluster(self): @@ -337,6 +337,14 @@ class VirtualMachineTest(APITestCase): self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1) self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1) self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1) + self.virtualmachine_with_context_data = VirtualMachine.objects.create( + name='VM with context data', + cluster=self.cluster1, + local_context_data={ + 'A': 1, + 'B': 2 + } + ) def test_get_virtualmachine(self): @@ -350,7 +358,7 @@ class VirtualMachineTest(APITestCase): url = reverse('virtualization-api:virtualmachine-list') response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) + self.assertEqual(response.data['count'], 4) def test_list_virtualmachines_brief(self): @@ -373,7 +381,7 @@ class VirtualMachineTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VirtualMachine.objects.count(), 4) + self.assertEqual(VirtualMachine.objects.count(), 5) virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id']) self.assertEqual(virtualmachine4.name, data['name']) self.assertEqual(virtualmachine4.cluster.pk, data['cluster']) @@ -388,7 +396,7 @@ class VirtualMachineTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - self.assertEqual(VirtualMachine.objects.count(), 3) + self.assertEqual(VirtualMachine.objects.count(), 4) def test_create_virtualmachine_bulk(self): @@ -411,7 +419,7 @@ class VirtualMachineTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VirtualMachine.objects.count(), 6) + self.assertEqual(VirtualMachine.objects.count(), 7) 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']) @@ -438,7 +446,7 @@ class VirtualMachineTest(APITestCase): response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(VirtualMachine.objects.count(), 3) + self.assertEqual(VirtualMachine.objects.count(), 4) virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id']) self.assertEqual(virtualmachine1.name, data['name']) self.assertEqual(virtualmachine1.cluster.pk, data['cluster']) @@ -451,7 +459,22 @@ class VirtualMachineTest(APITestCase): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(VirtualMachine.objects.count(), 2) + self.assertEqual(VirtualMachine.objects.count(), 3) + + def test_config_context_included_by_default_in_list_view(self): + + url = reverse('virtualization-api:virtualmachine-list') + url = '{}?id={}'.format(url, self.virtualmachine_with_context_data.pk) + 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('virtualization-api:virtualmachine-list') + '?exclude=config_context' + response = self.client.get(url, **self.header) + + self.assertFalse('config_context' in response.data['results'][0]) class InterfaceTest(APITestCase): @@ -466,17 +489,17 @@ class InterfaceTest(APITestCase): self.interface1 = Interface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 1', - form_factor=IFACE_FF_VIRTUAL + type=IFACE_TYPE_VIRTUAL ) self.interface2 = Interface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 2', - form_factor=IFACE_FF_VIRTUAL + type=IFACE_TYPE_VIRTUAL ) self.interface3 = Interface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 3', - form_factor=IFACE_FF_VIRTUAL + type=IFACE_TYPE_VIRTUAL ) self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 1b86e2015..b967eeefc 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -3,14 +3,16 @@ import urllib.parse from django.test import Client, TestCase from django.urls import reverse +from utilities.testing import create_test_user from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine class ClusterGroupTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['virtualization.view_clustergroup']) self.client = Client() + self.client.force_login(user) ClusterGroup.objects.bulk_create([ ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), @@ -29,8 +31,9 @@ class ClusterGroupTestCase(TestCase): class ClusterTypeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['virtualization.view_clustertype']) self.client = Client() + self.client.force_login(user) ClusterType.objects.bulk_create([ ClusterType(name='Cluster Type 1', slug='cluster-type-1'), @@ -49,8 +52,9 @@ class ClusterTypeTestCase(TestCase): class ClusterTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['virtualization.view_cluster']) self.client = Client() + self.client.force_login(user) clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1') clustergroup.save() @@ -85,8 +89,9 @@ class ClusterTestCase(TestCase): class VirtualMachineTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['virtualization.view_virtualmachine']) self.client = Client() + self.client.force_login(user) clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1') clustertype.save() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index aa8a585a9..712c7acb5 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -22,7 +22,8 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine # Cluster types # -class ClusterTypeListView(ObjectListView): +class ClusterTypeListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'virtualization.view_clustertype' queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable template_name = 'virtualization/clustertype_list.html' @@ -57,7 +58,8 @@ class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Cluster groups # -class ClusterGroupListView(ObjectListView): +class ClusterGroupListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'virtualization.view_clustergroup' queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable template_name = 'virtualization/clustergroup_list.html' @@ -92,7 +94,8 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Clusters # -class ClusterListView(ObjectListView): +class ClusterListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'virtualization.view_cluster' queryset = Cluster.objects.select_related('type', 'group', 'site') table = tables.ClusterTable filter = filters.ClusterFilter @@ -100,7 +103,8 @@ class ClusterListView(ObjectListView): template_name = 'virtualization/cluster_list.html' -class ClusterView(View): +class ClusterView(PermissionRequiredMixin, View): + permission_required = 'virtualization.view_cluster' def get(self, request, pk): @@ -247,7 +251,8 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View): # Virtual machines # -class VirtualMachineListView(ObjectListView): +class VirtualMachineListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'virtualization.view_virtualmachine' queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6') filter = filters.VirtualMachineFilter filter_form = forms.VirtualMachineFilterForm @@ -255,7 +260,8 @@ class VirtualMachineListView(ObjectListView): template_name = 'virtualization/virtualmachine_list.html' -class VirtualMachineView(View): +class VirtualMachineView(PermissionRequiredMixin, View): + permission_required = 'virtualization.view_virtualmachine' def get(self, request, pk): @@ -270,7 +276,8 @@ class VirtualMachineView(View): }) -class VirtualMachineConfigContextView(ObjectConfigContextView): +class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): + permission_required = 'virtualization.view_virtualmachine' object_class = VirtualMachine base_template = 'virtualization/virtualmachine.html' diff --git a/requirements.txt b/requirements.txt index b69ce9475..3ad165a4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,22 @@ Django>=2.2,<2.3 -django-cors-headers==2.4.0 -django-debug-toolbar==1.11 -django-filter==2.0.0 +django-cacheops==4.1 +django-cors-headers==3.0.2 +django-debug-toolbar==2.0 +django-filter==2.1.0 django-mptt==0.9.1 -django-tables2==2.0.3 -django-taggit==0.23.0 +django-prometheus==1.0.15 +django-rq==2.1.0 +django-tables2==2.0.6 +django-taggit==1.1.0 django-taggit-serializer==0.1.7 django-timezone-field==3.0 -djangorestframework==3.9.1 -drf-yasg[validation]==1.14.0 +djangorestframework==3.9.4 +drf-yasg[validation]==1.16.0 graphviz==0.10.1 -Jinja2==2.10 +Jinja2==2.10.1 Markdown==2.6.11 netaddr==0.7.19 -Pillow==5.3.0 -psycopg2-binary==2.7.6.1 +Pillow==6.0.0 +psycopg2-binary==2.8.3 py-gfm==0.1.4 -pycryptodome==3.7.2 +pycryptodome==3.8.2