Merge pull request #3271 from digitalocean/develop

Release v2.6.0
This commit is contained in:
Jeremy Stretch 2019-06-20 16:59:31 -04:00 committed by GitHub
commit 2b7e8c4b9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
180 changed files with 6809 additions and 1797 deletions

View File

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

View File

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

View File

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

View File

@ -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
```

View File

@ -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
```

View File

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

View File

@ -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, "name": "MyNewDevice",
"site": 7, "rack": 123,
"group": 4, ...
"vid": 102,
"name": "Users-Floor2",
"tenant": null,
"status": 1,
"role": 9,
"description": ""
} }
``` ```
Or by a set of nested attributes used to identify the rack:
```
{
"name": "MyNewDevice",
"rack": {
"site": {
"name": "Equinix DC6"
},
"name": "R204"
},
...
}
```
Note that if the provided parameters do not return exactly one object, a validation error is raised.
## Brief Format ## Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form. Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
@ -261,12 +274,31 @@ A list of objects retrieved via the API can be filtered by passing one or more q
GET /api/ipam/prefixes/?status=1 GET /api/ipam/prefixes/?status=1
``` ```
Certain filters can be included multiple times within a single request. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes: The choices available for fixed choice fields such as `status` are exposed in the API under a special `_choices` endpoint for each NetBox app. For example, the available choices for `Prefix.status` are listed at `/api/ipam/_choices/` under the key `prefix:status`:
``` ```
GET /api/ipam/prefixes/?status=1&status=2 "prefix:status": [
{
"label": "Container",
"value": 0
},
{
"label": "Active",
"value": 1
},
{
"label": "Reserved",
"value": 2
},
{
"label": "Deprecated",
"value": 3
}
],
``` ```
For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar".
## Custom Fields ## Custom Fields
To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123: To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

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

View File

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

View File

@ -4,13 +4,15 @@ from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from circuits.models import Circuit, CircuitType, Provider from circuits.models import Circuit, CircuitType, Provider
from utilities.testing import create_test_user
class ProviderTestCase(TestCase): class ProviderTestCase(TestCase):
def setUp(self): def setUp(self):
user = create_test_user(permissions=['circuits.view_provider'])
self.client = Client() self.client = Client()
self.client.force_login(user)
Provider.objects.bulk_create([ Provider.objects.bulk_create([
Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 1', slug='provider-1', asn=65001),
@ -38,8 +40,9 @@ class ProviderTestCase(TestCase):
class CircuitTypeTestCase(TestCase): class CircuitTypeTestCase(TestCase):
def setUp(self): def setUp(self):
user = create_test_user(permissions=['circuits.view_circuittype'])
self.client = Client() self.client = Client()
self.client.force_login(user)
CircuitType.objects.bulk_create([ CircuitType.objects.bulk_create([
CircuitType(name='Circuit Type 1', slug='circuit-type-1'), CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
@ -58,8 +61,9 @@ class CircuitTypeTestCase(TestCase):
class CircuitTestCase(TestCase): class CircuitTestCase(TestCase):
def setUp(self): def setUp(self):
user = create_test_user(permissions=['circuits.view_circuit'])
self.client = Client() self.client = Client()
self.client.force_login(user)
provider = Provider(name='Provider 1', slug='provider-1', asn=65001) provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
provider.save() provider.save()
@ -84,8 +88,8 @@ class CircuitTestCase(TestCase):
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_provider(self): def test_circuit(self):
provider = Provider.objects.first() circuit = Circuit.objects.first()
response = self.client.get(provider.get_absolute_url()) response = self.client.get(circuit.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View File

@ -40,10 +40,11 @@ urlpatterns = [
path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations # Circuit terminations
path(r'circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), path(r'circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path(r'circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path(r'circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path(r'circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path(r'circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path(r'circuit-terminations/<int:termination_a_id>/connect/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), path(r'circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), path(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

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

View File

@ -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'),
),
]

View File

@ -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',
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,8 @@ from secrets.views import secret_add
from . import views from . import views
from .models import ( from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site,
VirtualChassis,
) )
app_name = 'dcim' app_name = 'dcim'
@ -162,7 +163,7 @@ urlpatterns = [
path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
path(r'devices/<int:pk>/console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), path(r'devices/<int:pk>/console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path(r'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), path(r'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path(r'console-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path(r'console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
@ -170,8 +171,9 @@ urlpatterns = [
# Console server ports # Console server ports
path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
path(r'devices/<int:pk>/console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), path(r'devices/<int:pk>/console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
path(r'devices/<int:pk>/console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
path(r'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), path(r'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path(r'console-server-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path(r'console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
@ -182,7 +184,7 @@ urlpatterns = [
path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
path(r'devices/<int:pk>/power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), path(r'devices/<int:pk>/power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path(r'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), path(r'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path(r'power-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path(r'power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
@ -190,8 +192,9 @@ urlpatterns = [
# Power outlets # Power outlets
path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
path(r'devices/<int:pk>/power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), path(r'devices/<int:pk>/power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
path(r'devices/<int:pk>/power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
path(r'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), path(r'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path(r'power-outlets/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path(r'power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
@ -203,7 +206,7 @@ urlpatterns = [
path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
path(r'devices/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), path(r'devices/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
path(r'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), path(r'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path(r'interfaces/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'), path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'), path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path(r'interfaces/<int:pk>/assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), path(r'interfaces/<int:pk>/assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
@ -218,7 +221,7 @@ urlpatterns = [
path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'), path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
path(r'devices/<int:pk>/front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), path(r'devices/<int:pk>/front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
path(r'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), path(r'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path(r'front-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), path(r'front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
@ -230,7 +233,7 @@ urlpatterns = [
path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'), path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
path(r'devices/<int:pk>/rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), path(r'devices/<int:pk>/rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
path(r'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), path(r'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path(r'rear-ports/<int:termination_a_id>/connect/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path(r'rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
@ -280,4 +283,25 @@ urlpatterns = [
path(r'virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), path(r'virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path(r'virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), path(r'virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
# Power panels
path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
path(r'power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
path(r'power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path(r'power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path(r'power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
# Power feeds
path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
path(r'power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
path(r'power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path(r'power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path(r'power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path(r'power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,11 @@
import django_filters import django_filters
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
class CustomFieldFilter(django_filters.Filter): class CustomFieldFilter(django_filters.Filter):

View File

@ -5,19 +5,18 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from taggit.forms import TagField from taggit.forms import TagField
from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField,
FilterChoiceField, LaxURLField, JSONField, SlugField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField,
) )
from .constants import ( from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
OBJECTCHANGE_ACTION_CHOICES, OBJECTCHANGE_ACTION_CHOICES,
) )
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
# #
@ -189,11 +188,12 @@ class CustomFieldFilterForm(forms.Form):
class TagForm(BootstrapMixin, forms.ModelForm): class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField() slug = SlugField()
comments = CommentField()
class Meta: class Meta:
model = Tag model = Tag
fields = [ fields = [
'name', 'slug', 'name', 'slug', 'color', 'comments'
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

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

22
netbox/extras/signals.py Normal file
View File

@ -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)

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
from django.urls import path from django.urls import path
from extras import views from extras import views
from extras.models import Tag
app_name = 'extras' app_name = 'extras'
urlpatterns = [ urlpatterns = [
@ -11,6 +13,7 @@ urlpatterns = [
path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'), path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'), path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'), path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path(r'tags/<slug:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
# Config contexts # Config contexts
path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

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

View File

@ -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.-]+$')]),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More