diff --git a/base_requirements.txt b/base_requirements.txt
index caf7ba5f3..a57e88604 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -42,10 +42,6 @@ django-tables2
# https://github.com/alex/django-taggit
django-taggit
-# A Django REST Framework serializer which represents tags
-# https://github.com/glemmaPaul/django-taggit-serializer
-django-taggit-serializer
-
# A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/
django-timezone-field
diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md
index 0e6513602..359d65202 100644
--- a/docs/additional-features/caching.md
+++ b/docs/additional-features/caching.md
@@ -1,21 +1,25 @@
# 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)
+NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../../configuration/optional-settings/#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache.
-Several management commands are avaliable for administrators to manually invalidate cache entries in extenuating circumstances.
+If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database.
-To invalidate a specifc model instance (for example a Device with ID 34):
-```
-python netbox/manage.py invalidate dcim.Device.34
+## Invalidating Cached Data
+
+Although caching is performed automatically and rarely requires administrative intervention, NetBox provides the `invalidate` management command to force invalidation of cached results. This command can reference a specific object my its type and numeric ID:
+
+```no-highlight
+$ python netbox/manage.py invalidate dcim.Device.34
```
-To invalidate all instance of a model:
-```
-python netbox/manage.py invalidate dcim.Device
+Alternatively, it can also delete all cached results for an object type:
+
+```no-highlight
+$ python netbox/manage.py invalidate dcim.Device
```
-To flush the entire cache database:
-```
-python netbox/manage.py invalidate all
+Finally, calling it with the `all` argument will force invalidation of the entire cache database:
+
+```no-highlight
+$ python netbox/manage.py invalidate all
```
diff --git a/docs/additional-features/change-logging.md b/docs/additional-features/change-logging.md
index b359f9b26..d580ccc6c 100644
--- a/docs/additional-features/change-logging.md
+++ b/docs/additional-features/change-logging.md
@@ -1,9 +1,9 @@
# Change Logging
-Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object is saved to the database, along with meta data including the current time and the user associated with the change. These records form a running changelog both for each individual object as well as NetBox as a whole (Organization > Changelog).
+Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object is saved to the database, along with meta data including the current time and the user associated with the change. These records form a persistent record of changes both for each individual object as well as NetBox as a whole. The global change log can be viewed by navigating to Other > Change Log.
-A serialized representation is included for each object in JSON format. This is similar to how objects are conveyed within the REST API, but does not include any nested representations. For instance, the `tenant` field of a site will record only the tenant's ID, not a representation of the tenant.
+A serialized representation of the instance being modified is included in JSON format. This is similar to how objects are conveyed within the REST API, but does not include any nested representations. For instance, the `tenant` field of a site will record only the tenant's ID, not a representation of the tenant.
-When a request is made, a random request ID is generated and attached to any change records resulting from the request. For example, editing multiple objects in bulk will create a change record for each object, and each of those objects will be assigned the same request ID. This makes it easy to identify all the change records associated with a particular request.
+When a request is made, a UUID is generated and attached to any change records resulting from that request. For example, editing three objects in bulk will create a separate change record for each (three in total), and each of those objects will be associated with the same UUID. This makes it easy to identify all the change records resulting from a particular request.
-Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported in CSV format.
+Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported via the web UI in CSV format.
diff --git a/docs/additional-features/context-data.md b/docs/additional-features/context-data.md
deleted file mode 100644
index 432203f92..000000000
--- a/docs/additional-features/context-data.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Context Data
-
-{!docs/models/extras/configcontext.md!}
diff --git a/docs/additional-features/custom-links.md b/docs/additional-features/custom-links.md
index 56d67a7be..7be837529 100644
--- a/docs/additional-features/custom-links.md
+++ b/docs/additional-features/custom-links.md
@@ -1,6 +1,6 @@
# Custom Links
-Custom links allow users to place arbitrary hyperlinks within NetBox views. These are helpful for cross-referencing related records in external systems. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
+Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside of NetBox. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links are created under the admin UI. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
@@ -11,7 +11,7 @@ For example, you might define a link like this:
When viewing a device named Router4, this link would render as:
-```
+```no-highlight
View NMS
```
@@ -23,21 +23,20 @@ Only links which render with non-empty text are included on the page. You can em
For example, if you only want to display a link for active devices, you could set the link text to
-```
+```jinja2
{% if obj.status == 'active' %}View NMS{% endif %}
```
The link will not appear when viewing a device with any status other than "active."
-Another example, if you want to only show an object of a certain manufacturer, you could set the link text to:
+As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this:
-```
-{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS {% endif %}
+```jinja2
+{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %}
```
The link will only appear when viewing a device with a manufacturer name of "Cisco."
## Link Groups
-You can specify a group name to organize links into related sets. Grouped links will render as a dropdown menu beneath a
-single button bearing the name of the group.
+Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group.
diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md
index a2b4191ec..128739780 100644
--- a/docs/additional-features/custom-scripts.md
+++ b/docs/additional-features/custom-scripts.md
@@ -6,22 +6,24 @@ Custom scripting was introduced to provide a way for users to execute custom log
* Create a range of new reserved prefixes or IP addresses
* Fetch data from an external source and import it to NetBox
-Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're written from scratch, a custom script can be used to accomplish just about anything.
+Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish.
## Writing Custom Scripts
All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity.
-```
+```python
from extras.scripts import Script
class MyScript(Script):
- ..
+ ...
```
-Scripts comprise two core components: variables and a `run()` method. Variables allow your script to accept user input via the NetBox UI. The `run()` method is where your script's execution logic lives. (Note that your script can have as many methods as needed: this is merely the point of invocation for NetBox.)
+Scripts comprise two core components: a set of variables and a `run()` method. Variables allow your script to accept user input via the NetBox UI, but they are optional: If your script does not require any user input, there is no need to define any variables.
-```
+The `run()` method is where your script's execution logic lives. (Note that your script can have as many methods as needed: this is merely the point of invocation for NetBox.)
+
+```python
class MyScript(Script):
var1 = StringVar(...)
var2 = IntegerVar(...)
@@ -37,17 +39,17 @@ The `run()` method should accept two arguments:
* `commit` - A boolean indicating whether database changes will be committed.
!!! note
- The `commit` argument was introduced in NetBox v2.7.8. Backward compatibility is maintained for scripts which accept only the `data` argument, however moving forward scripts should accept both arguments.
+ The `commit` argument was introduced in NetBox v2.7.8. Backward compatibility is maintained for scripts which accept only the `data` argument, however beginning with v2.10 NetBox will require the `run()` method of every script to accept both arguments. (Either argument may still be ignored within the method.)
-Defining variables is optional: You may create a script with only a `run()` method if no user input is needed.
+Defining script variables is optional: You may create a script with only a `run()` method if no user input is needed.
-Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI.
+Any output generated by the script during its execution will be displayed under the "output" tab in the UI.
## Module Attributes
### `name`
-You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the filename will be used.
+You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the module's file name will be used.
## Script Attributes
@@ -61,19 +63,11 @@ This is the human-friendly names of your script. If omitted, the class name will
A human-friendly description of what your script does.
-### `field_order`
-
-A list of field names indicating the order in which the form fields should appear. This is optional, and should not be required on Python 3.6 and above. For example:
-
-```
-field_order = ['var1', 'var2', 'var3']
-```
-
### `commit_default`
The checkbox to commit database changes when executing a script is checked by default. Set `commit_default` to False under the script's Meta class to leave this option unchecked by default.
-```
+```python
commit_default = False
```
@@ -83,8 +77,9 @@ Details of the current HTTP request (the one being made to execute the script) a
```python
username = self.request.user.username
-ip_address = self.request.META.get('HTTP_X_FORWARDED_FOR') or self.request.META.get('REMOTE_ADDR')
-self.log_info("Running as user {} (IP: {})...".format(username, ip_address))
+ip_address = self.request.META.get('HTTP_X_FORWARDED_FOR') or \
+ self.request.META.get('REMOTE_ADDR')
+self.log_info(f"Running as user {username} (IP: {ip_address})...")
```
For a complete list of available request parameters, please see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/request-response/).
@@ -112,30 +107,40 @@ Log messages are returned to the user upon execution of the script. Markdown ren
## Variable Reference
+### Default Options
+
+All custom script variables support the following default options:
+
+* `default` - The field's default value
+* `description` - A brief user-friendly description of the field
+* `label` - The field name to be displayed in the rendered form
+* `required` - Indicates whether the field is mandatory (all fields are required by default)
+* `widget` - The class of form widget to use (see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/forms/widgets/))
+
### StringVar
-Stores a string of characters (i.e. a line of text). Options include:
+Stores a string of characters (i.e. text). Options include:
* `min_length` - Minimum number of characters
* `max_length` - Maximum number of characters
* `regex` - A regular expression against which the provided value must match
-Note: `min_length` and `max_length` can be set to the same number to effect a fixed-length field.
+Note that `min_length` and `max_length` can be set to the same number to effect a fixed-length field.
### TextVar
-Arbitrary text of any length. Renders as multi-line text input field.
+Arbitrary text of any length. Renders as a multi-line text input field.
### IntegerVar
-Stored a numeric integer. Options include:
+Stores a numeric integer. Options include:
* `min_value` - Minimum value
* `max_value` - Maximum value
### BooleanVar
-A true/false flag. This field has no options beyond the defaults.
+A true/false flag. This field has no options beyond the defaults listed above.
### ChoiceVar
@@ -154,15 +159,54 @@ CHOICES = (
direction = ChoiceVar(choices=CHOICES)
```
+In the example above, selecting the choice labeled "North" will submit the value `n`.
+
### MultiChoiceVar
Similar to `ChoiceVar`, but allows for the selection of multiple choices.
### ObjectVar
-A NetBox object of a particular type, identified by the associated queryset. Most models will utilize the REST API to retrieve available options: Note that any filtering on the queryset in this case has no effect.
+A particular object within NetBox. Each ObjectVar must specify a particular model, and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below.
-* `queryset` - The base [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) for the model
+* `model` - The model class
+* `display_field` - The name of the REST API object field to display in the selection list (default: `'name'`)
+* `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
+* `null_option` - A label representing a "null" or empty choice (optional)
+
+The `display_field` argument is useful when referencing a model which does not have a `name` field. For example, when displaying a list of device types, you would likely use the `model` field:
+
+```python
+device_type = ObjectVar(
+ model=DeviceType,
+ display_field='model'
+)
+```
+
+To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
+
+```python
+device = ObjectVar(
+ model=Device,
+ query_params={
+ 'status': 'active'
+ }
+)
+```
+
+Multiple values can be specified by assigning a list to the dictionary key. It is also possible to reference the value of other fields in the form by prepending a dollar sign (`$`) to the variable's name.
+
+```python
+region = ObjectVar(
+ model=Region
+)
+site = ObjectVar(
+ model=Site,
+ query_params={
+ 'region_id': '$region'
+ }
+)
+```
### MultiObjectVar
@@ -170,7 +214,7 @@ Similar to `ObjectVar`, but allows for the selection of multiple objects.
### FileVar
-An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
+An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be automatically saved for future use. The script is responsible for writing file contents to disk where necessary.
### IPAddressVar
@@ -184,18 +228,8 @@ An IPv4 or IPv6 address with a mask. Returns a `netaddr.IPNetwork` object which
An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two attributes are available to validate the provided mask:
-* `min_prefix_length` - Minimum length of the mask (default: none)
-* `max_prefix_length` - Maximum length of the mask (default: none)
-
-### Default Options
-
-All variables support the following default options:
-
-* `default` - The field's default value
-* `description` - A brief description of the field
-* `label` - The name of the form field
-* `required` - Indicates whether the field is mandatory (default: true)
-* `widget` - The class of form widget to use (see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/forms/widgets/))
+* `min_prefix_length` - Minimum length of the mask
+* `max_prefix_length` - Maximum length of the mask
## Example
@@ -207,11 +241,11 @@ Below is an example script that creates new objects for a planned site. The user
These variables are presented as a web form to be completed by the user. Once submitted, the script's `run()` method is called to create the appropriate objects.
-```
+```python
from django.utils.text import slugify
from dcim.choices import DeviceStatusChoices, SiteStatusChoices
-from dcim.models import Device, DeviceRole, DeviceType, Site
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from extras.scripts import *
@@ -228,9 +262,17 @@ class NewBranchScript(Script):
switch_count = IntegerVar(
description="Number of access switches to create"
)
+ manufacturer = ObjectVar(
+ model=Manufacturer,
+ required=False
+ )
switch_model = ObjectVar(
description="Access switch model",
- queryset = DeviceType.objects.all()
+ model=DeviceType,
+ display_field='model',
+ query_params={
+ 'manufacturer_id': '$manufacturer'
+ }
)
def run(self, data, commit):
@@ -242,20 +284,20 @@ class NewBranchScript(Script):
status=SiteStatusChoices.STATUS_PLANNED
)
site.save()
- self.log_success("Created new site: {}".format(site))
+ self.log_success(f"Created new site: {site}")
# Create access switches
switch_role = DeviceRole.objects.get(name='Access Switch')
for i in range(1, data['switch_count'] + 1):
switch = Device(
device_type=data['switch_model'],
- name='{}-switch{}'.format(site.slug, i),
+ name=f'{site.slug}-switch{i}',
site=site,
status=DeviceStatusChoices.STATUS_PLANNED,
device_role=switch_role
)
switch.save()
- self.log_success("Created new switch: {}".format(switch))
+ self.log_success(f"Created new switch: {switch}")
# Generate a CSV table of new devices
output = [
diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md
index 541858a88..b7bbc9842 100644
--- a/docs/additional-features/export-templates.md
+++ b/docs/additional-features/export-templates.md
@@ -4,9 +4,14 @@ NetBox allows users to define custom templates that can be used when exporting o
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
-Export templates are written in [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
+Export templates may be written in Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2.
-```
+!!! warning
+ Support for Django's native templating logic will be removed in NetBox v2.10.
+
+The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
+
+```jinja2
{% for rack in queryset %}
Rack: {{ rack.name }}
Site: {{ rack.site.name }}
diff --git a/docs/additional-features/graphs.md b/docs/additional-features/graphs.md
index 264b7f1b7..e3551b91f 100644
--- a/docs/additional-features/graphs.md
+++ b/docs/additional-features/graphs.md
@@ -1,5 +1,8 @@
# Graphs
+!!! warning
+ Native support for embedded graphs is due to be removed in NetBox v2.10. It will likely be superseded by a plugin providing similar functionality.
+
NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters:
* **Type:** Site, device, provider, or interface. This determines in which view the graph will be displayed.
@@ -8,10 +11,7 @@ NetBox does not have the ability to generate graphs natively, but this feature a
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.
* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`.
-Graph names and links can be rendered using the Django or Jinja2 template languages.
-
-!!! warning
- Support for the Django templating language will be removed in NetBox v2.8. Jinja2 is recommended.
+Graph names and links can be rendered using Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/).
## Examples
diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md
index 304d892c4..c8a3665b9 100644
--- a/docs/additional-features/napalm.md
+++ b/docs/additional-features/napalm.md
@@ -1,11 +1,13 @@
# NAPALM
-NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API.
+NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
-!!! info
- To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm-automation-optional) for more information.
+!!! note
+ To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information.
-```
+Below is an example REST API request and response:
+
+```no-highlight
GET /api/dcim/devices/1/napalm/?method=get_environment
{
@@ -15,13 +17,16 @@ GET /api/dcim/devices/1/napalm/?method=get_environment
}
```
+!!! note
+ To make NAPALM requests via the NetBox REST API, a NetBox user must have assigned a permission granting the `napalm_read` action for the device object type.
+
## Authentication
-By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) are used for NAPALM authentication. They can be overridden for an individual API call through the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
+By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
--H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \
+-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
-H "X-NAPALM-Username: foo" \
@@ -30,13 +35,13 @@ $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
## Method Support
-The list of supported NAPALM methods depends on the [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html#general-support-matrix) configured for the platform of a device. NetBox only supports [get](https://napalm.readthedocs.io/en/latest/support/index.html#getters-support-matrix) methods.
+The list of supported NAPALM methods depends on the [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html#general-support-matrix) configured for the platform of a device. Because there is no granular mechanism in place for limiting potentially disruptive requests, NetBox supports only read-only [get](https://napalm.readthedocs.io/en/latest/support/index.html#getters-support-matrix) methods.
## Multiple Methods
-More than one method in an API call can be invoked by adding multiple `method` parameters. For example:
+It is possible to request the output of multiple NAPALM methods in a single API request by passing multiple `method` parameters. For example:
-```
+```no-highlight
GET /api/dcim/devices/1/napalm/?method=get_ntp_servers&method=get_ntp_peers
{
@@ -51,14 +56,11 @@ GET /api/dcim/devices/1/napalm/?method=get_ntp_servers&method=get_ntp_peers
## Optional Arguments
-The behavior of NAPALM drivers can be adjusted according to the [optional arguments](https://napalm.readthedocs.io/en/latest/support/index.html#optional-arguments). NetBox exposes those arguments using headers prefixed with `X-NAPALM-`.
-
-
-For instance, the SSH port is changed to 2222 in this API call:
+The behavior of NAPALM drivers can be adjusted according to the [optional arguments](https://napalm.readthedocs.io/en/latest/support/index.html#optional-arguments). NetBox exposes those arguments using headers prefixed with `X-NAPALM-`. For example, the SSH port is changed to 2222 in this API call:
```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
--H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \
+-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
-H "X-NAPALM-port: 2222"
diff --git a/docs/additional-features/prometheus-metrics.md b/docs/additional-features/prometheus-metrics.md
index 1429fb0a7..048eb68b3 100644
--- a/docs/additional-features/prometheus-metrics.md
+++ b/docs/additional-features/prometheus-metrics.md
@@ -23,16 +23,7 @@ For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on you
## 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.
+When deploying NetBox in a multiprocess manner (e.g. running multiple Gunicorn workers) the Prometheus client library requires the use of a shared directory to collect metrics from all worker processes. To configure this, first create or designate a local directory to which the worker processes have read and write access, and then configure your WSGI service (e.g. Gunicorn) to define this path as 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
-```
-
-#### Accuracy
-
-If having accurate long-term metrics in a multiprocess environment is important to you then it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
\ No newline at end of file
+!!! warning
+ If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
\ No newline at end of file
diff --git a/docs/additional-features/tags.md b/docs/additional-features/tags.md
deleted file mode 100644
index 0609524f4..000000000
--- a/docs/additional-features/tags.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Tagging
-
-{!docs/models/extras/tag.md!}
diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md
index de06c50b7..845a6745d 100644
--- a/docs/additional-features/webhooks.md
+++ b/docs/additional-features/webhooks.md
@@ -1,6 +1,6 @@
# Webhooks
-A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever a device status is changed in NetBox. This can be done by creating a webhook for the device model in NetBox. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks.
+A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks.
## Configuration
@@ -8,7 +8,7 @@ A webhook is a mechanism for conveying to some external system a change that too
* **Object type(s)** - The type or types of NetBox object that will trigger the webhook.
* **Enabled** - If unchecked, the webhook will be inactive.
* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected.
-* **HTTP method** - The type of HTTP request to send. Options include GET, POST, PUT, PATCH, and DELETE.
+* **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`.
* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed.
* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`)
* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
@@ -19,13 +19,13 @@ A webhook is a mechanism for conveying to some external system a change that too
## Jinja2 Template Support
-[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey change data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
+[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration:
* Object type: IPAM > IP address
-* HTTP method: POST
-* URL:
+* HTTP method: `POST`
+* URL: Slack incoming webhook URL
* HTTP content type: `application/json`
* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}`
diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md
index 34cd5a30f..51a06156a 100644
--- a/docs/administration/netbox-shell.md
+++ b/docs/administration/netbox-shell.md
@@ -1,12 +1,12 @@
# The NetBox Python Shell
-NetBox includes a Python shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
+NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
```
./manage.py nbshell
```
-This will launch a customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
+This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
```
$ ./manage.py nbshell
@@ -28,13 +28,17 @@ DCIM:
...
```
+!!! warning
+ The NetBox shell affords direct access to NetBox data and function with very little validation in place. As such, it is crucial to ensure that only authorized, knowledgeable users are ever granted access to it. Never perform any action in the management shell without having a full backup in place.
+
## Querying Objects
-Objects are retrieved by forming a [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `.objects.all()`, which will return a (truncated) list of all objects of that type.
+Objects are retrieved from the database using a [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `.objects.all()`, which will return a (truncated) list of all objects of that type.
```
>>> Device.objects.all()
-, , , , , '...(remaining elements truncated)...']>
+, , ,
+, , '...(remaining elements truncated)...']>
```
Use a `for` loop to cycle through all objects in the list:
@@ -43,11 +47,11 @@ Use a `for` loop to cycle through all objects in the list:
>>> for device in Device.objects.all():
... print(device.name, device.device_type)
...
-(u'TestDevice1', )
-(u'TestDevice2', )
-(u'TestDevice3', )
-(u'TestDevice4', )
-(u'TestDevice5', )
+('TestDevice1', )
+('TestDevice2', )
+('TestDevice3', )
+('TestDevice4', )
+('TestDevice5', )
...
```
@@ -67,52 +71,53 @@ To retrieve a particular object (typically by its primary key or other unique fi
### Filtering Querysets
-In most cases, you want to retrieve only a specific subset of objects. To filter a queryset, replace `all()` with `filter()` and pass one or more keyword arguments. For example:
+In most cases, you will want to retrieve only a specific subset of objects. To filter a queryset, replace `all()` with `filter()` and pass one or more keyword arguments. For example:
```
->>> Device.objects.filter(status=STATUS_ACTIVE)
-, , , , , '...(remaining elements truncated)...']>
+>>> Device.objects.filter(status="active")
+, , ,
+, , '...(remaining elements truncated)...']>
```
Querysets support slicing to return a specific range of objects.
```
->>> Device.objects.filter(status=STATUS_ACTIVE)[:3]
+>>> Device.objects.filter(status="active")[:3]
, , ]>
```
The `count()` method can be appended to the queryset to return a count of objects rather than the full list.
```
->>> Device.objects.filter(status=STATUS_ACTIVE).count()
+>>> Device.objects.filter(status="active").count()
982
```
-Relationships with other models can be traversed by concatenating field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
+Relationships with other models can be traversed by concatenating attribute names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper."
```
->>> Device.objects.filter(tenant__name='Pied Piper')
+>>> Device.objects.filter(tenant__name="Pied Piper")
```
This approach can span multiple levels of relations. For example, the following will return all IP addresses assigned to a device in North America:
```
->>> IPAddress.objects.filter(interface__device__site__region__slug='north-america')
+>>> IPAddress.objects.filter(interface__device__site__region__slug="north-america")
```
!!! note
- While the above query is functional, it is very inefficient. There are ways to optimize such requests, however they are out of the scope of this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/stable/ref/models/querysets/) documentation.
+ While the above query is functional, it's not very efficient. There are ways to optimize such requests, however they are out of scope for this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/stable/ref/models/querysets/) documentation.
Reverse relationships can be traversed as well. For example, the following will find all devices with an interface named "em0":
```
->>> Device.objects.filter(interfaces__name='em0')
+>>> Device.objects.filter(interfaces__name="em0")
```
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
```
->>> Device.objects.filter(name__icontains='testdevice')
+>>> Device.objects.filter(name__icontains="testdevice")
```
Similarly, numeric fields can be filtered by values less than, greater than, and/or equal to a given value.
@@ -124,7 +129,7 @@ Similarly, numeric fields can be filtered by values less than, greater than, and
Multiple filters can be combined to further refine a queryset.
```
->>> VLAN.objects.filter(vid__gt=2000, name__icontains='engineering')
+>>> VLAN.objects.filter(vid__gt=2000, name__icontains="engineering")
```
To return the inverse of a filtered queryset, use `exclude()` instead of `filter()`.
@@ -132,18 +137,18 @@ To return the inverse of a filtered queryset, use `exclude()` instead of `filter
```
>>> Device.objects.count()
4479
->>> Device.objects.filter(status=STATUS_ACTIVE).count()
+>>> Device.objects.filter(status="active").count()
4133
->>> Device.objects.exclude(status=STATUS_ACTIVE).count()
+>>> Device.objects.exclude(status="active").count()
346
```
!!! info
- The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API docs](https://docs.djangoproject.com/en/stable/ref/models/querysets/).
+ The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API documentation](https://docs.djangoproject.com/en/stable/ref/models/querysets/).
## Creating and Updating Objects
-New objects can be created by instantiating the desired model, defining values for all required attributes, and calling `save()` on the instance.
+New objects can be created by instantiating the desired model, defining values for all required attributes, and calling `save()` on the instance. For example, we can create a new VLAN by specifying its numeric ID, name, and assigned site:
```
>>> lab1 = Site.objects.get(pk=7)
@@ -151,22 +156,22 @@ New objects can be created by instantiating the desired model, defining values f
>>> myvlan.save()
```
-Alternatively, the above can be performed as a single operation:
+Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.)
```
>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save()
```
-To modify an object, retrieve it, update the desired field(s), and call `save()` again.
+To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again.
```
>>> vlan = VLAN.objects.get(pk=1280)
>>> vlan.name
-u'MyNewVLAN'
+'MyNewVLAN'
>>> vlan.name = 'BetterName'
>>> vlan.save()
>>> VLAN.objects.get(pk=1280).name
-u'BetterName'
+'BetterName'
```
!!! warning
@@ -180,7 +185,7 @@ To delete an object, simply call `delete()` on its instance. This will return a
>>> vlan
>>> vlan.delete()
-(1, {u'extras.CustomFieldValue': 0, u'ipam.VLAN': 1})
+(1, {'extras.CustomFieldValue': 0, 'ipam.VLAN': 1})
```
To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them.
@@ -189,8 +194,10 @@ To delete multiple objects at once, call `delete()` on a filtered queryset. It's
>>> Device.objects.filter(name__icontains='test').count()
27
>>> Device.objects.filter(name__icontains='test').delete()
-(35, {u'extras.CustomFieldValue': 0, u'dcim.DeviceBay': 0, u'secrets.Secret': 0, u'dcim.InterfaceConnection': 4, u'extras.ImageAttachment': 0, u'dcim.Device': 27, u'dcim.Interface': 4, u'dcim.ConsolePort': 0, u'dcim.PowerPort': 0})
+(35, {'extras.CustomFieldValue': 0, 'dcim.DeviceBay': 0, 'secrets.Secret': 0,
+'dcim.InterfaceConnection': 4, 'extras.ImageAttachment': 0, 'dcim.Device': 27,
+'dcim.Interface': 4, 'dcim.ConsolePort': 0, 'dcim.PowerPort': 0})
```
!!! warning
- Deletions are immediate and irreversible. Always think very carefully before calling `delete()` on an instance or queryset.
+ Deletions are immediate and irreversible. Always consider the impact of deleting objects carefully before calling `delete()` on an instance or queryset.
diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md
new file mode 100644
index 000000000..c66c65543
--- /dev/null
+++ b/docs/administration/permissions.md
@@ -0,0 +1,45 @@
+# Permissions
+
+NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
+
+{!docs/models/users/objectpermission.md!}
+
+### Example Constraint Definitions
+
+| Constraints | Description |
+| ----------- | ----------- |
+| `{"status": "active"}` | Status is active |
+| `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved |
+| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing |
+| `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
+| `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
+| `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |
+| `[{"vid__lt": 200}, {"status": "reserved"}]` | VLAN ID is less than 200 **OR** status is reserved |
+
+## Permissions Enforcement
+
+### Viewing Objects
+
+Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
+
+If the permission _has_ been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
+
+```json
+[
+ {"site__name__in": ["NYC1", "NYC2"]},
+ {"status": "offline", "tenant__isnull": true}
+]
+```
+
+This grants the user access to view any device that is assigned to a site named NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints are equivalent to the following ORM query:
+
+```no-highlight
+Site.objects.filter(
+ Q(site__name__in=['NYC1', 'NYC2']),
+ Q(status='active', tenant__isnull=True)
+)
+```
+
+### Creating and Modifying Objects
+
+The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the constraints imposed by the permission. The transaction is then rolled back, leaving the database in its original state prior to the change, and the user is informed of the violation.
diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md
index bb7157d45..7fa07517c 100644
--- a/docs/administration/replicating-netbox.md
+++ b/docs/administration/replicating-netbox.md
@@ -2,7 +2,7 @@
## Replicating the Database
-NetBox uses [PostgreSQL](https://www.postgresql.org/) for its database, so general PostgreSQL best practices will apply to NetBox. You can dump and restore the database using the `pg_dump` and `psql` utilities, respectively.
+NetBox employs a [PostgreSQL](https://www.postgresql.org/) database, so general PostgreSQL best practices apply here. The database can be written to a file and restored using the `pg_dump` and `psql` utilities, respectively.
!!! note
The examples below assume that your database is named `netbox`.
@@ -23,8 +23,10 @@ pg_dump --exclude-table-data=extras_objectchange netbox > netbox.sql
### Load an Exported Database
+When restoring a database from a file, it's recommended to delete any existing database first to avoid potential conflicts.
+
!!! warning
- This will destroy and replace any existing instance of the database.
+ The following will destroy and replace any existing instance of the database.
```no-highlight
psql -c 'drop database netbox'
@@ -41,17 +43,15 @@ If you want to export only the database schema, and not the data itself (e.g. fo
```no-highlight
pg_dump -s netbox > netbox_schema.sql
```
-If you are migrating your instance of NetBox to a different machine, please make sure you invalidate the cache by performing this command:
-
-```no-highlight
-python3 manage.py invalidate all
-```
---
-## Replicating Media
+## Replicating Uploaded Media
-NetBox stored uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files.
+By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files.
+
+!!! note
+ These operations are not necessary if your installation is utilizing a [remote storage backend](../../configuration/optional-settings/#storage_backend).
### Archive the Media Directory
@@ -68,3 +68,13 @@ To extract the saved archive into a new installation, run the following from the
```no-highlight
tar -xf netbox_media.tar.gz
```
+
+---
+
+## Cache Invalidation
+
+If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache by performing this command:
+
+```no-highlight
+python3 manage.py invalidate all
+```
diff --git a/docs/api/authentication.md b/docs/api/authentication.md
deleted file mode 100644
index e8e6ddc96..000000000
--- a/docs/api/authentication.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# REST API Authentication
-
-The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API.
-
-{!docs/models/users/token.md!}
-
-## Authenticating to the API
-
-By default, read operations will be available without authentication. In this case, a token may be included in the request, but is not necessary.
-
-```
-$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
-{
- "count": 10,
- "next": null,
- "previous": null,
- "results": [...]
-}
-```
-
-However, if the [`LOGIN_REQUIRED`](../../configuration/optional-settings/#login_required) configuration setting has been set to `True`, all requests must be authenticated.
-
-```
-$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
-{
- "detail": "Authentication credentials were not provided."
-}
-```
-
-To authenticate to the API, set the HTTP `Authorization` header to the string `Token ` (note the trailing space) followed by the token key.
-
-```
-$ curl -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
-{
- "count": 10,
- "next": null,
- "previous": null,
- "results": [...]
-}
-```
-
-Additionally, the browsable interface to the API (which can be seen by navigating to the API root `/api/` in a web browser) will attempt to authenticate requests using the same cookie that the normal NetBox front end uses. Thus, if you have logged into NetBox, you will be logged into the browsable API as well.
diff --git a/docs/api/examples.md b/docs/api/examples.md
deleted file mode 100644
index f4348907f..000000000
--- a/docs/api/examples.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# API Examples
-
-Supported HTTP methods:
-
-* `GET`: Retrieve an object or list of objects
-* `POST`: Create a new object
-* `PUT`: Update an existing object, all mandatory fields must be specified
-* `PATCH`: Updates an existing object, only specifying the field to be changed
-* `DELETE`: Delete an existing object
-
-To authenticate a request, attach your token in an `Authorization` header:
-
-```
-curl -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0"
-```
-
-## Retrieving a list of sites
-
-Send a `GET` request to the object list endpoint. The response contains a paginated list of JSON objects.
-
-```
-$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
-{
- "count": 14,
- "next": null,
- "previous": null,
- "results": [
- {
- "id": 6,
- "name": "Corporate HQ",
- "slug": "corporate-hq",
- "region": null,
- "tenant": null,
- "facility": "",
- "asn": null,
- "physical_address": "742 Evergreen Terrace, Springfield, USA",
- "shipping_address": "",
- "contact_name": "",
- "contact_phone": "",
- "contact_email": "",
- "comments": "",
- "custom_fields": {},
- "count_prefixes": 108,
- "count_vlans": 46,
- "count_racks": 8,
- "count_devices": 254,
- "count_circuits": 6
- },
- ...
- ]
-}
-```
-
-## Retrieving a single site by ID
-
-Send a `GET` request to the object detail endpoint. The response contains a single JSON object.
-
-```
-$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/6/
-{
- "id": 6,
- "name": "Corporate HQ",
- "slug": "corporate-hq",
- "region": null,
- "tenant": null,
- "facility": "",
- "asn": null,
- "physical_address": "742 Evergreen Terrace, Springfield, USA",
- "shipping_address": "",
- "contact_name": "",
- "contact_phone": "",
- "contact_email": "",
- "comments": "",
- "custom_fields": {},
- "count_prefixes": 108,
- "count_vlans": 46,
- "count_racks": 8,
- "count_devices": 254,
- "count_circuits": 6
-}
-```
-
-## Creating a new site
-
-Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required. This example includes one non required field, "region."
-
-```
-$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site", "region": 5}'
-{
- "id": 16,
- "name": "My New Site",
- "slug": "my-new-site",
- "region": 5,
- "tenant": null,
- "facility": "",
- "asn": null,
- "physical_address": "",
- "shipping_address": "",
- "contact_name": "",
- "contact_phone": "",
- "contact_email": "",
- "comments": ""
-}
-```
-Note that in this example we are creating a site bound to a region with the ID of 5. For write API actions (`POST`, `PUT`, and `PATCH`) the integer ID value is used for `ForeignKey` (related model) relationships, instead of the nested representation that is used in the `GET` (list) action.
-
-## Modify an existing site
-
-Make an authenticated `PUT` request to the site detail endpoint. As with a create (`POST`) request, all mandatory fields must be included.
-
-```
-$ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}'
-```
-
-## Modify an object by changing a field
-
-Make an authenticated `PATCH` request to the device endpoint. With `PATCH`, unlike `POST` and `PUT`, we only specify the field that is being changed. In this example, we add a serial number to a device.
-```
-$ curl -X PATCH -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/2549/ --data '{"serial": "FTX1123A090"}'
-```
-
-## Delete an existing site
-
-Send an authenticated `DELETE` request to the site detail endpoint.
-
-```
-$ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/
-* Connected to localhost (127.0.0.1) port 8000 (#0)
-> DELETE /api/dcim/sites/16/ HTTP/1.1
-> User-Agent: curl/7.35.0
-> Host: localhost:8000
-> Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
-> Content-Type: application/json
-> Accept: application/json; indent=4
->
-* HTTP 1.0, assume close after body
-< HTTP/1.0 204 No Content
-< Date: Mon, 20 Mar 2017 16:13:08 GMT
-< Server: WSGIServer/0.1 Python/2.7.6
-< Vary: Accept, Cookie
-< X-Frame-Options: SAMEORIGIN
-< Allow: GET, PUT, PATCH, DELETE, OPTIONS
-<
-* Closing connection 0
-```
-
-The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.
-
-
-## Bulk Object Creation
-
-The REST API supports the creation of multiple objects of the same type using a single `POST` request. For example, to create multiple devices:
-
-```
-curl -X POST -H "Authorization: Token " -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/ --data '[
-{"name": "device1", "device_type": 24, "device_role": 17, "site": 6},
-{"name": "device2", "device_type": 24, "device_role": 17, "site": 6},
-{"name": "device3", "device_type": 24, "device_role": 17, "site": 6},
-]'
-```
-
-Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. A successful response returns an HTTP code 201 and the body of the response will be a list/array of the objects created.
\ No newline at end of file
diff --git a/docs/api/filtering.md b/docs/api/filtering.md
deleted file mode 100644
index 6bc47e75b..000000000
--- a/docs/api/filtering.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# API Filtering
-
-The NetBox API supports robust filtering of results based on the fields of each model.
-Generally speaking you are able to filter based on the attributes (fields) present in
-the response body. Please note however that certain read-only or metadata fields are not
-filterable.
-
-Filtering is achieved by passing HTTP query parameters and the parameter name is the
-name of the field you wish to filter on and the value is the field value.
-
-E.g. filtering based on a device's name:
-```
-/api/dcim/devices/?name=DC-SPINE-1
-```
-
-## Multi Value Logic
-
-While you are able to filter based on an arbitrary number of fields, you are also able to
-pass multiple values for the same field. In most cases filtering on multiple values is
-implemented as a logical OR operation. A notable exception is the `tag` filter which
-is a logical AND. Passing multiple values for one field, can be combined with other fields.
-
-For example, filtering for devices with either the name of DC-SPINE-1 _or_ DC-LEAF-4:
-```
-/api/dcim/devices/?name=DC-SPINE-1&name=DC-LEAF-4
-```
-
-Filtering for devices with tag `router` and `customer-a` will return only devices with
-_both_ of those tags applied:
-```
-/api/dcim/devices/?tag=router&tag=customer-a
-```
-
-## Lookup Expressions
-
-Certain model fields also support filtering using additional lookup expressions. This allows
-for negation and other context specific filtering.
-
-These lookup expressions can be applied by adding a suffix to the desired field's name.
-E.g. `mac_address__n`. In this case, the filter expression is for negation and it is separated
-by two underscores. Below are the lookup expressions that are supported across different field
-types.
-
-### Numeric Fields
-
-Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
-
-- `n` - not equal (negation)
-- `lt` - less than
-- `lte` - less than or equal
-- `gt` - greater than
-- `gte` - greater than or equal
-
-### String Fields
-
-String based (char) fields (Name, Address, etc) support these lookup expressions:
-
-- `n` - not equal (negation)
-- `ic` - case insensitive contains
-- `nic` - negated case insensitive contains
-- `isw` - case insensitive starts with
-- `nisw` - negated case insensitive starts with
-- `iew` - case insensitive ends with
-- `niew` - negated case insensitive ends with
-- `ie` - case sensitive exact match
-- `nie` - negated case sensitive exact match
-
-### Foreign Keys & Other Fields
-
-Certain other fields, namely foreign key relationships support just the negation
-expression: `n`.
diff --git a/docs/api/overview.md b/docs/api/overview.md
deleted file mode 100644
index 90372aa2a..000000000
--- a/docs/api/overview.md
+++ /dev/null
@@ -1,295 +0,0 @@
-# The NetBox REST API
-
-## What is a REST API?
-
-REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP to create, retrieve, update, and delete objects from a database. (This set of operations is commonly referred to as CRUD.) Each type of operation is associated with a particular HTTP verb:
-
-* `GET`: Retrieve an object or list of objects
-* `POST`: Create an object
-* `PUT` / `PATCH`: Modify an existing object. `PUT` requires all mandatory fields to be specified, while `PATCH` only expects the field that is being modified to be specified.
-* `DELETE`: Delete an existing object
-
-The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)
-
-```
-$ curl -s http://localhost:8000/api/ipam/ip-addresses/2954/ | jq '.'
-{
- "custom_fields": {},
- "nat_outside": null,
- "nat_inside": null,
- "description": "An example IP address",
- "id": 2954,
- "family": 4,
- "address": "5.101.108.132/26",
- "vrf": null,
- "tenant": null,
- "status": {
- "label": "Active",
- "value": 1
- },
- "role": null,
- "interface": null
-}
-```
-
-Each attribute of the NetBox object is expressed as a field in the dictionary. Fields may include their own nested objects, as in the case of the `status` field above. Every object includes a primary key named `id` which uniquely identifies it in the database.
-
-## Interactive Documentation
-
-Comprehensive, interactive documentation of all API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with NetBox's various API endpoints and different request types.
-
-## URL Hierarchy
-
-NetBox's entire API is housed under the API root at `https:///api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application:
-
-* /api/circuits/providers/
-* /api/circuits/circuits/
-
-Likewise, the site, rack, and device objects are located under the "DCIM" application:
-
-* /api/dcim/sites/
-* /api/dcim/racks/
-* /api/dcim/devices/
-
-The full hierarchy of available endpoints can be viewed by navigating to the API root in a web browser.
-
-Each model generally has two views associated with it: a list view and a detail view. The list view is used to request a list of multiple objects or to create a new object. The detail view is used to retrieve, update, or delete an existing object. All objects are referenced by their numeric primary key (`id`).
-
-* /api/dcim/devices/ - List devices or create a new device
-* /api/dcim/devices/123/ - Retrieve, update, or delete the device with ID 123
-
-Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123:
-
-```
-GET /api/dcim/interfaces/?device_id=123
-```
-
-See [filtering](filtering.md) for more details.
-
-## Serialization
-
-The NetBox API employs three types of serializers to represent model data:
-
-* Base serializer
-* Nested serializer
-* Writable serializer
-
-The base serializer is used to represent the default view of a model. This includes all database table fields which comprise the model, and may include additional metadata. A base serializer includes relationships to parent objects, but **does not** include child objects. For example, the `VLANSerializer` includes a nested representation its parent VLANGroup (if any), but does not include any assigned Prefixes.
-
-```
-{
- "id": 1048,
- "site": {
- "id": 7,
- "url": "http://localhost:8000/api/dcim/sites/7/",
- "name": "Corporate HQ",
- "slug": "corporate-hq"
- },
- "group": {
- "id": 4,
- "url": "http://localhost:8000/api/ipam/vlan-groups/4/",
- "name": "Production",
- "slug": "production"
- },
- "vid": 101,
- "name": "Users-Floor1",
- "tenant": null,
- "status": {
- "value": 1,
- "label": "Active"
- },
- "role": {
- "id": 9,
- "url": "http://localhost:8000/api/ipam/roles/9/",
- "name": "User Access",
- "slug": "user-access"
- },
- "description": "",
- "display_name": "101 (Users-Floor1)",
- "custom_fields": {}
-}
-```
-
-### Related Objects
-
-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):
-
-```
-{
- "name": "MyNewDevice",
- "rack": 123,
- ...
-}
-```
-
-Or by a set of nested attributes used to identify the rack:
-
-```
-{
- "name": "MyNewDevice",
- "rack": {
- "site": {
- "name": "Equinix DC6"
- },
- "name": "R204"
- },
- ...
-}
-```
-
-Note that if the provided parameters do not return exactly one object, a validation error is raised.
-
-### Brief Format
-
-Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
-
-For example, the default (complete) format of an IP address looks like this:
-
-```
-GET /api/ipam/prefixes/13980/
-
-{
- "id": 13980,
- "family": 4,
- "prefix": "192.0.2.0/24",
- "site": null,
- "vrf": null,
- "tenant": null,
- "vlan": null,
- "status": {
- "value": 1,
- "label": "Active"
- },
- "role": null,
- "is_pool": false,
- "description": "",
- "tags": [],
- "custom_fields": {},
- "created": "2018-12-11",
- "last_updated": "2018-12-11T16:27:55.073174-05:00"
-}
-```
-
-The brief format is much more terse, but includes a link to the object's full representation:
-
-```
-GET /api/ipam/prefixes/13980/?brief=1
-
-{
- "id": 13980,
- "url": "https://netbox/api/ipam/prefixes/13980/",
- "family": 4,
- "prefix": "192.0.2.0/24"
-}
-```
-
-The brief format is supported for both lists and individual objects.
-
-## Pagination
-
-API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes:
-
-* `count`: The total count of all objects matching the query
-* `next`: A hyperlink to the next page of results (if applicable)
-* `previous`: A hyperlink to the previous page of results (if applicable)
-* `results`: The list of returned objects
-
-Here is an example of a paginated response:
-
-```
-HTTP 200 OK
-Allow: GET, POST, OPTIONS
-Content-Type: application/json
-Vary: Accept
-
-{
- "count": 2861,
- "next": "http://localhost:8000/api/dcim/devices/?limit=50&offset=50",
- "previous": null,
- "results": [
- {
- "id": 123,
- "name": "DeviceName123",
- ...
- },
- ...
- ]
-}
-```
-
-The default page size derives from the [`PAGINATE_COUNT`](../../configuration/optional-settings/#paginate_count) configuration setting, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
-
-```
-http://localhost:8000/api/dcim/devices/?limit=100
-```
-
-The response will return devices 1 through 100. The URL provided in the `next` attribute of the response will return devices 101 through 200:
-
-```
-{
- "count": 2861,
- "next": "http://localhost:8000/api/dcim/devices/?limit=100&offset=100",
- "previous": null,
- "results": [...]
-}
-```
-
-The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../../configuration/optional-settings/#max_page_size) setting, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
-
-!!! warning
- Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
-
-## Filtering
-
-A list of objects retrieved via the API can be filtered by passing one or more query parameters. The same parameters used by the web UI work for the API as well. For example, to return only prefixes with a status of "Active" (identified by the slug `active`):
-
-```
-GET /api/ipam/prefixes/?status=active
-```
-
-The choices available for fixed choice fields such as `status` can be retrieved by sending an `OPTIONS` API request for the desired endpoint:
-
-```no-highlight
-$ curl -s -X OPTIONS \
--H "Authorization: Token $TOKEN" \
--H "Content-Type: application/json" \
--H "Accept: application/json; indent=4" \
-http://localhost:8000/api/ipam/prefixes/ | jq ".actions.POST.status.choices"
-[
- {
- "value": "container",
- "display_name": "Container"
- },
- {
- "value": "active",
- "display_name": "Active"
- },
- {
- "value": "reserved",
- "display_name": "Reserved"
- },
- {
- "value": "deprecated",
- "display_name": "Deprecated"
- }
-]
-```
-
-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".
-
-### Excluding Config Contexts
-
-The rendered config context for devices and VMs is included by default in all API results (list and detail views). Users with large amounts of context data will most likely observe a performance drop when returning multiple objects, particularly with page sizes in the high hundreds or more. 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.
-
-### 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:
-
-```
-GET /api/dcim/sites/?cf_foo=123
-```
-
-!!! note
- Full versus partial matching when filtering is configurable per custom field. Filtering can be toggled (or disabled) for a custom field in the admin UI.
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
index 26b1aa5eb..8b0c4121a 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration/index.md
@@ -1,6 +1,6 @@
# NetBox Configuration
-NetBox's local configuration is stored in `netbox/netbox/configuration.py`. An example configuration is provided at `netbox/netbox/configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.
+NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file.
While NetBox has many configuration settings, only a few of them must be defined at the time of installation.
@@ -11,8 +11,8 @@ While NetBox has many configuration settings, only a few of them must be defined
## Changing the Configuration
-Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect:
+Configuration settings may be changed at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect:
```no-highlight
-# sudo supervisorctl restart netbox
+$ sudo systemctl restart netbox
```
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index e938a4125..244ffc120 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -4,7 +4,7 @@
NetBox will email details about critical errors to the administrators listed here. This should be a list of (name, email) tuples. For example:
-```
+```python
ADMINS = [
['Hank Hill', 'hhill@example.com'],
['Dale Gribble', 'dgribble@example.com'],
@@ -17,7 +17,7 @@ ADMINS = [
Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
-A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate the entire default list as well (excluding those schemes which are not desirable).
+A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable).
---
@@ -25,9 +25,9 @@ A list of permitted URL schemes referenced when rendering links within NetBox. N
## BANNER_BOTTOM
-Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
+Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
-```
+```python
BANNER_TOP = 'Your banner text'
BANNER_BOTTOM = BANNER_TOP
```
@@ -36,7 +36,7 @@ BANNER_BOTTOM = BANNER_TOP
## BANNER_LOGIN
-The value of this variable will be displayed on the login page above the login form. HTML is allowed.
+This defines custom content to be displayed on the login page above the login form. HTML is allowed.
---
@@ -46,7 +46,7 @@ Default: None
The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set:
-```
+```python
BASE_PATH = 'netbox/'
```
@@ -56,7 +56,7 @@ BASE_PATH = 'netbox/'
Default: 900
-The number of seconds to retain cache entries before automatically invalidating them.
+The number of seconds to cache entries will be retained before expiring.
---
@@ -64,7 +64,11 @@ The number of seconds to retain cache entries before automatically invalidating
Default: 90
-The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain changes in the database indefinitely. (Warning: This will greatly increase database size over time.)
+The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
+changes in the database indefinitely.
+
+!!! warning
+ If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
---
@@ -80,9 +84,11 @@ If True, cross-origin resource sharing (CORS) requests will be accepted from all
## CORS_ORIGIN_REGEX_WHITELIST
-These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) For example:
+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:
-```
+```python
CORS_ORIGIN_WHITELIST = [
'https://example.com',
]
@@ -94,12 +100,13 @@ CORS_ORIGIN_WHITELIST = [
Default: False
-This setting enables debugging. This should be done only during development or troubleshooting. Note that only clients
-which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user
+This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
+clients which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user
interface.
!!! warning
- Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
+ Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users and impose a
+ substantial performance penalty.
---
@@ -113,9 +120,9 @@ This parameter serves as a safeguard to prevent some potentially dangerous behav
## DOCS_ROOT
-Default: `$INSTALL_DIR/docs/`
+Default: `$INSTALL_ROOT/docs/`
-The file path to NetBox's documentation. This is used when presenting context-sensitive documentation in the web UI. by default, this will be the `docs/` directory within the root NetBox installation path. (Set this to `None` to disable the embedded documentation.)
+The filesystem path to NetBox's documentation. This is used when presenting context-sensitive documentation in the web UI. By default, this will be the `docs/` directory within the root NetBox installation path. (Set this to `None` to disable the embedded documentation.)
---
@@ -123,20 +130,23 @@ The file path to NetBox's documentation. This is used when presenting context-se
In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter:
-* `SERVER` - Host name or IP address of the email server (use `localhost` if running locally)
+* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally)
* `PORT` - TCP port to use for the connection (default: `25`)
* `USERNAME` - Username with which to authenticate
* `PASSSWORD` - Password with which to authenticate
-* `USE_SSL` - Use SSL when connecting to the server (default: `False`). Mutually exclusive with `USE_TLS`.
-* `USE_TLS` - Use TLS when connecting to the server (default: `False`). Mutually exclusive with `USE_SSL`.
+* `USE_SSL` - Use SSL when connecting to the server (default: `False`)
+* `USE_TLS` - Use TLS when connecting to the server (default: `False`)
* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)
* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional)
* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`)
-* `FROM_EMAIL` - Sender address for emails sent by NetBox (default: `root@localhost`)
+* `FROM_EMAIL` - Sender address for emails sent by NetBox
-Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
+!!! note
+ The `USE_SSL` and `USE_TLS` parameters are mutually exclusive.
-```
+Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) fuction accessible within the NetBox shell:
+
+```no-highlight
# python ./manage.py nbshell
>>> from django.core.mail import send_mail
>>> send_mail(
@@ -150,15 +160,23 @@ Email is sent from NetBox only for critical events or if configured for [logging
---
+## ENFORCE_GLOBAL_UNIQUE
+
+Default: False
+
+By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True.
+
+---
+
## 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.
+A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous.
List models in the form `.`. For example:
-```
+```python
EXEMPT_VIEW_PERMISSIONS = [
'dcim.site',
'dcim.region',
@@ -168,17 +186,12 @@ EXEMPT_VIEW_PERMISSIONS = [
To exempt _all_ models from view permission enforcement, set the following. (Note that `EXEMPT_VIEW_PERMISSIONS` must be an iterable.)
-```
+```python
EXEMPT_VIEW_PERMISSIONS = ['*']
```
----
-
-## ENFORCE_GLOBAL_UNIQUE
-
-Default: False
-
-Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table (all prefixes and IP addresses not assigned to a VRF), set `ENFORCE_GLOBAL_UNIQUE` to True.
+!!! note
+ Using a wildcard will not affect certain potentially sensitive models, such as user permissions. If there is a need to exempt these models, they must be specified individually.
---
@@ -186,7 +199,7 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni
Default: None
-A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhooks). Proxies should be specified by schema as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example:
+A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example:
```python
HTTP_PROXIES = {
@@ -199,7 +212,7 @@ HTTP_PROXIES = {
## INTERNAL_IPS
-Default: `('127.0.0.1', '::1',)`
+Default: `('127.0.0.1', '::1')`
A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
@@ -209,11 +222,11 @@ addresses (and [`DEBUG`](#debug) is true).
## LOGGING
-By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
+By default, all messages of INFO severity or higher will be logged to the console. Additionally, if [`DEBUG`](#debug) is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in [`ADMINS`](#admins).
-The Django framework on which NetBox runs allows for the customization of logging, e.g. to write logs to file. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a file:
+The Django framework on which NetBox runs allows for the customization of logging format and destination. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a local file:
-```
+```python
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
@@ -235,6 +248,7 @@ LOGGING = {
### Available Loggers
+* `netbox..` - Generic form for model-specific log messages
* `netbox.auth.*` - Authentication events
* `netbox.api.views.*` - Views which handle business logic for the REST API
* `netbox.reports.*` - Report execution (`module.name`)
@@ -255,7 +269,7 @@ Setting this to True will permit only authenticated users to access any part of
Default: 1209600 seconds (14 days)
-The liftetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
+The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login.
---
@@ -271,13 +285,13 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
Default: 1000
-An API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This setting defines the maximum limit. Setting it to `0` or `None` will allow an API consumer to request all objects by specifying `?limit=0`.
+A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`.
---
## MEDIA_ROOT
-Default: $BASE_DIR/netbox/media/
+Default: $INSTALL_ROOT/netbox/media/
The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path.
@@ -287,7 +301,7 @@ The file path to the location where media files (such as image attachments) are
Default: False
-Toggle exposing Prometheus metrics at `/metrics`. See the [Prometheus Metrics](../../additional-features/prometheus-metrics/) documentation for more details.
+Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../../additional-features/prometheus-metrics/) documentation for more details.
---
@@ -297,7 +311,8 @@ Toggle exposing Prometheus metrics at `/metrics`. See the [Prometheus Metrics](.
NetBox will use these credentials when authenticating to remote devices via the [NAPALM library](https://napalm-automation.net/), if installed. Both parameters are optional.
-Note: If SSH public key authentication has been set up for the system account under which NetBox runs, these parameters are not needed.
+!!! note
+ If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
---
@@ -305,16 +320,16 @@ Note: If SSH public key authentication has been set up for the system account un
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
-```
+```python
NAPALM_ARGS = {
'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
'port': 2222,
}
```
-Note: Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
+Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
-```
+```python
NAPALM_USERNAME = 'username'
NAPALM_PASSWORD = 'MySecretPassword'
NAPALM_ARGS = {
@@ -337,7 +352,7 @@ The amount of time (in seconds) to wait for NAPALM to connect to a device.
Default: 50
-Determine how many objects to display per page within each list of objects.
+The default maximum number of objects to display per page within each list of objects.
---
@@ -398,30 +413,6 @@ Default width (in pixels) of a unit within a rack elevation.
---
-## REMOTE_AUTH_ENABLED
-
-Default: `False`
-
-NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
-
----
-
-## REMOTE_AUTH_BACKEND
-
-Default: `'utilities.auth_backends.RemoteUserBackend'`
-
-Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.)
-
----
-
-## REMOTE_AUTH_HEADER
-
-Default: `'HTTP_REMOTE_USER'`
-
-When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. (Requires `REMOTE_AUTH_ENABLED`.)
-
----
-
## REMOTE_AUTH_AUTO_CREATE_USER
Default: `False`
@@ -430,6 +421,17 @@ If true, NetBox will automatically create local accounts for users authenticated
---
+## REMOTE_AUTH_BACKEND
+
+Default: `'netbox.authentication.RemoteUserBackend'`
+
+This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins.
+
+* `netbox.authentication.RemoteUserBackend`
+* `netbox.authentication.LDAPBackend`
+
+---
+
## REMOTE_AUTH_DEFAULT_GROUPS
Default: `[]` (Empty list)
@@ -440,9 +442,25 @@ The list of groups to assign a new user account when created using remote authen
## REMOTE_AUTH_DEFAULT_PERMISSIONS
-Default: `[]` (Empty list)
+Default: `{}` (Empty dictionary)
-The list of permissions to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.)
+A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
+
+---
+
+## REMOTE_AUTH_ENABLED
+
+Default: `False`
+
+NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
+
+---
+
+## REMOTE_AUTH_HEADER
+
+Default: `'HTTP_REMOTE_USER'`
+
+When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. (Requires `REMOTE_AUTH_ENABLED`.)
---
@@ -456,17 +474,18 @@ The number of seconds to retain the latest version that is fetched from the GitH
## RELEASE_CHECK_URL
-Default: None
+Default: None (disabled)
-The releases of this repository are checked to detect new releases, which are shown on the home page of the web interface. You can change this to your own fork of the NetBox repository, or set it to `None` to disable the check. The URL provided **must** be compatible with the GitHub API.
+This parameter defines the URL of the repository that will be checked periodically for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks.
-Use `'https://api.github.com/repos/netbox-community/netbox/releases'` to check for release in the official NetBox repository.
+!!! note
+ The URL provided **must** be compatible with the [GitHub REST API](https://docs.github.com/en/rest).
---
## REPORTS_ROOT
-Default: $BASE_DIR/netbox/reports/
+Default: `$INSTALL_ROOT/netbox/reports/`
The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path.
@@ -474,7 +493,7 @@ The file path to the location where custom reports will be kept. By default, thi
## SCRIPTS_ROOT
-Default: $BASE_DIR/netbox/scripts/
+Default: `$INSTALL_ROOT/netbox/scripts/`
The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.
@@ -484,7 +503,7 @@ The file path to the location where custom scripts will be kept. By default, thi
Default: None
-Session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in the PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the user as which NetBox runs must have read and write permissions to this path.
+HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path.
---
@@ -512,21 +531,19 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
Default: UTC
-The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. [List of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
+The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
---
## Date and Time Formatting
-You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date).
+You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below.
-Defaults:
-
-```
+```python
DATE_FORMAT = 'N j, Y' # June 26, 2016
-SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-27
+SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26
TIME_FORMAT = 'g:i a' # 1:23 p.m.
SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00
DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m.
-SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-27 13:23
+SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-26 13:23
```
diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md
index 053e2d3d4..d54b13e38 100644
--- a/docs/configuration/required-settings.md
+++ b/docs/configuration/required-settings.md
@@ -2,7 +2,12 @@
## ALLOWED_HOSTS
-This is a list of valid fully-qualified domain names (FQDNs) that is used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different (e.g. when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server). NetBox will not permit access to the server via any other hostnames (or IPs). The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts `HTTP POST` to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, has `USE_X_FORWARDED_HOST = True` (in `netbox/netbox/settings.py`) which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
+This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attackes](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs).
+
+!!! note
+ This parameter must always be defined as a list or tuple, even if only value is provided.
+
+The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to true, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
Example:
@@ -10,18 +15,24 @@ Example:
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
```
+If you are not yet sure what the domain name and/or IP address of the NetBox installation will be, and are comfortable accepting the risks in doing so, you can set this to a wildcard (asterisk) to allow all host values:
+
+```
+ALLOWED_HOSTS = ['*']
+```
+
---
## DATABASE
-NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
+NetBox requires access to a PostgreSQL 9.6 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* `NAME` - Database name
* `USER` - PostgreSQL username
* `PASSWORD` - PostgreSQL password
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
-* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
-* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (150-300 is recommended)
+* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (TCP/5432)
+* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (300 is the default)
Example:
@@ -57,7 +68,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
* `SSL` - Use SSL connection to Redis
-Example:
+An example configuration is provided below:
```python
REDIS = {
@@ -81,8 +92,9 @@ REDIS = {
```
!!! note
- If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have
- changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary
+ If you are upgrading from a NetBox release older than v2.7.0, please note that the Redis connection configuration
+ settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is
+ necessary
!!! warning
It is highly recommended to keep the task and cache databases separate. Using the same database number on the
@@ -125,17 +137,14 @@ REDIS = {
```
!!! note
- It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible
- for example to have the tasks database use sentinel via `HOST`/`PORT` and for caching to use Sentinel via
- `SENTINELS`/`SENTINEL_SERVICE`.
-
+ It is permissible to use Sentinel for only one database and not the other.
---
## SECRET_KEY
-This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
+This is a secret, random string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
-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 directly 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 `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key.
diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md
index 38572cb25..e5ab22f19 100644
--- a/docs/core-functionality/ipam.md
+++ b/docs/core-functionality/ipam.md
@@ -6,6 +6,7 @@
---
{!docs/models/ipam/prefix.md!}
+{!docs/models/ipam/role.md!}
---
diff --git a/docs/core-functionality/power.md b/docs/core-functionality/power.md
index 026acd602..571109936 100644
--- a/docs/core-functionality/power.md
+++ b/docs/core-functionality/power.md
@@ -5,34 +5,4 @@
# Example Power Topology
-Below is a simple diagram demonstrating how power is modeled in NetBox.
-
-!!! note
- The power feeds are connected to the same power panel for illustrative purposes; usually, you would have such feeds diversely connected to panels to avoid the single point of failure.
-
-```
- +---------------+
- | Power panel 1 |
- +---------------+
- | |
- | |
-+--------------+ +--------------+
-| Power feed 1 | | Power feed 2 |
-+--------------+ +--------------+
- | |
- | |
- | | <-- Power ports
- +---------+ +---------+
- | PDU 1 | | PDU 2 |
- +---------+ +---------+
- | \ / | <-- Power outlets
- | \ / |
- | \ / |
- | X |
- | / \ |
- | / \ |
- | / \ | <-- Power ports
- +--------+ +--------+
- | Server | | Router |
- +--------+ +--------+
-```
+
diff --git a/docs/core-functionality/virtualization.md b/docs/core-functionality/virtualization.md
index b2bab2b7d..f406a59f3 100644
--- a/docs/core-functionality/virtualization.md
+++ b/docs/core-functionality/virtualization.md
@@ -1,4 +1,4 @@
-# Virtual Machines and Clusters
+# Virtualization
{!docs/models/virtualization/cluster.md!}
{!docs/models/virtualization/clustertype.md!}
@@ -7,3 +7,4 @@
---
{!docs/models/virtualization/virtualmachine.md!}
+{!docs/models/virtualization/vminterface.md!}
diff --git a/docs/development/utility-views.md b/docs/development/utility-views.md
index a6e50f71e..3b9c1053d 100644
--- a/docs/development/utility-views.md
+++ b/docs/development/utility-views.md
@@ -4,6 +4,10 @@ Utility views are reusable views that handle common CRUD tasks, such as listing
## Individual Views
+### ObjectView
+
+Retrieve and display a single object.
+
### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md
index 933e32edc..20ddaa07f 100644
--- a/docs/installation/1-postgresql.md
+++ b/docs/installation/1-postgresql.md
@@ -3,7 +3,7 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning
- NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** supported.
+ NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** currently supported.
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
@@ -20,7 +20,7 @@ If a recent enough version of PostgreSQL is not available through your distribut
#### CentOS
-CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version.
+CentOS 7 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version.
```no-highlight
# yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
@@ -47,11 +47,11 @@ Then, start the service and enable it to run at boot:
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
!!! danger
- DO NOT USE THE PASSWORD FROM THE EXAMPLE.
+ **Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
```no-highlight
# sudo -u postgres psql
-psql (10.10)
+psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
Type "help" for help.
postgres=# CREATE DATABASE netbox;
@@ -68,7 +68,13 @@ postgres=# \q
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
```no-highlight
-# psql -U netbox -W -h localhost netbox
+# psql --username netbox --password --host localhost netbox
+Password for user netbox:
+psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
+SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
+Type "help" for help.
+
+netbox=> \q
```
If successful, you will enter a `netbox` prompt. Type `\q` to exit.
diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md
index 83424f156..20f0095d0 100644
--- a/docs/installation/2-redis.md
+++ b/docs/installation/2-redis.md
@@ -4,6 +4,9 @@
[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md).
+!!! note
+ NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details.
+
### Ubuntu
```no-highlight
diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index 895e4237f..05f6d825e 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -4,7 +4,10 @@ This section of the documentation discusses installing and configuring the NetBo
## Install System Packages
-Begin by installing all system packages required by NetBox and its dependencies. Note that beginning with NetBox v2.8, Python 3.6 or later is required.
+Begin by installing all system packages required by NetBox and its dependencies.
+
+!!! note
+ NetBox v2.8.0 and later require Python 3.6 or 3.7. This documentation assumes Python 3.6.
### Ubuntu
@@ -19,22 +22,32 @@ Begin by installing all system packages required by NetBox and its dependencies.
# easy_install-3.6 pip
```
+Before continuing with either platform, update pip (Python's package management tool) to its latest release:
+
+```no-highlight
+# pip install --upgrade pip
+```
+
## Download NetBox
-You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
+This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and decompressing the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch.
-### Option A: Download a Release
+### Option A: Download a Release Archive
-Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`.
+Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
```no-highlight
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
-# cd /opt/
-# ln -s netbox-X.Y.Z/ netbox
-# cd /opt/netbox/
+# ln -s /opt/netbox-X.Y.Z/ /opt/netbox
+# ls -l /opt | grep netbox
+lrwxrwxrwx 1 root root 13 Jul 20 13:44 netbox -> netbox-2.9.0/
+drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0
```
+!!! note
+ It is recommended to install NetBox in a directory named for its version number. For example, NetBox v2.9.0 would be installed into `/opt/netbox-2.9.0`, and a symlink from `/opt/netbox/` would point to this location. This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
+
### Option B: Clone the Git Repository
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
@@ -57,7 +70,7 @@ If `git` is not already installed, install it:
# yum install -y git
```
-Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
+Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
```no-highlight
# git clone -b master https://github.com/netbox-community/netbox.git .
@@ -70,9 +83,12 @@ Resolving deltas: 100% (1495/1495), done.
Checking connectivity... done.
```
-## Create the NetBox User
+!!! note
+ Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release.
-Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save local files.
+## Create the NetBox System User
+
+Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files.
#### Ubuntu
@@ -89,59 +105,16 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
# chown --recursive netbox /opt/netbox/netbox/media/
```
-## Set Up Python Environment
-
-We'll use a Python [virtual environment](https://docs.python.org/3.6/tutorial/venv.html) to ensure NetBox's required packages don't conflict with anything in the base system. This will create a directory named `venv` in our NetBox root.
-
-```no-highlight
-# python3 -m venv /opt/netbox/venv
-```
-
-Next, activate the virtual environment and install the required Python packages. You should see your console prompt change to indicate the active environment. (Activating the virtual environment updates your command shell to use the local copy of Python that we just installed for NetBox instead of the system's Python interpreter.)
-
-```no-highlight
-# source venv/bin/activate
-(venv) # pip3 install -r requirements.txt
-```
-
-### NAPALM Automation (Optional)
-
-NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package:
-
-```no-highlight
-(venv) # pip3 install napalm
-```
-
-To ensure NAPALM is automatically re-installed during future upgrades, create a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`) and list the `napalm` package:
-
-```no-highlight
-# echo napalm >> local_requirements.txt
-```
-
-### Remote File Storage (Optional)
-
-By default, NetBox will use the local filesystem to storage uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired backend](../../configuration/optional-settings/#storage_backend) in `configuration.py`.
-
-```no-highlight
-(venv) # pip3 install django-storages
-```
-
-Don't forget to add the `django-storages` package to `local_requirements.txt` to ensure it gets re-installed during future upgrades:
-
-```no-highlight
-# echo django-storages >> local_requirements.txt
-```
-
## 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`. This file will hold all of your local configuration parameters.
```no-highlight
-(venv) # cd netbox/netbox/
-(venv) # cp configuration.example.py configuration.py
+# cd /opt/netbox/netbox/netbox/
+# cp configuration.example.py configuration.py
```
-Open `configuration.py` with your preferred editor and set the following variables:
+Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations:
* `ALLOWED_HOSTS`
* `DATABASE`
@@ -150,19 +123,21 @@ Open `configuration.py` with your preferred editor and set the following variabl
### ALLOWED_HOSTS
-This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address.
-
-Example:
+This is a list of the valid hostnames and IP addresses by which this server can be reached. You must specify at least one name or IP address. (Note that this does not restrict the locations from which NetBox may be accessed: It is merely for [HTTP host header validation](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting).)
```python
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
```
+If you are not yet sure what the domain name and/or IP address of the NetBox installation will be, you can set this to a wildcard (asterisk) to allow all host values:
+
+```python
+ALLOWED_HOSTS = ['*']
+```
+
### DATABASE
-This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. See the [configuration documentation](../../configuration/required-settings/#database) for more detail on individual parameters.
-
-Example:
+This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](/configuration/required-settings/#database) for more detail on individual parameters.
```python
DATABASE = {
@@ -171,29 +146,31 @@ DATABASE = {
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
- 'CONN_MAX_AGE': 300, # Max database connection age
+ 'CONN_MAX_AGE': 300, # Max database connection age (seconds)
}
```
### REDIS
-Redis is a in-memory key-value store required as part of the NetBox installation. It is used for features such as webhooks and caching. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../../configuration/required-settings/#redis) for more detail on individual parameters.
+Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters.
+
+Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique database ID.
```python
REDIS = {
'tasks': {
- 'HOST': 'redis.example.com',
- 'PORT': 1234,
- 'PASSWORD': 'foobar',
- 'DATABASE': 0,
- 'DEFAULT_TIMEOUT': 300,
- 'SSL': False,
+ 'HOST': 'localhost', # Redis server
+ 'PORT': 6379, # Redis port
+ 'PASSWORD': '', # Redis password (optional)
+ 'DATABASE': 0, # Database ID
+ 'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
+ 'SSL': False, # Use SSL (optional)
},
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
- 'DATABASE': 1,
+ 'DATABASE': 1, # Unique ID for second database
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
@@ -202,37 +179,69 @@ REDIS = {
### SECRET_KEY
-Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
+This parameter must be assigned a randomly-generated key employed as a salt for hashing and related cryptographic functions. (Note, however, that it is _never_ directly used in the encryption of secret data.) This key must be unique to this installation and is recommended to be at least 50 characters long. It should not be shared outside the local system.
-You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
-
-!!! note
- In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
-
-## Run Database Migrations
-
-Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
+A simple Python script named `generate_secret_key.py` is provided in the parent directory to assist in generating a suitable key:
```no-highlight
-(venv) # cd /opt/netbox/netbox/
-(venv) # python3 manage.py migrate
-Operations to perform:
- Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
-Running migrations:
- Rendering model states... DONE
- Applying contenttypes.0001_initial... OK
- Applying auth.0001_initial... OK
- Applying admin.0001_initial... OK
- ...
+# python3 ../generate_secret_key.py
```
-If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py`
+!!! warning
+ In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
+
+When you have finished modifying the configuration, remember to save the file.
+
+## Optional Requirements
+
+All Python packages required by NetBox are listed in `requirements.txt` and will be installed automatically. NetBox also supports some optional packages. If desired, these packages must be listed in `local_requirements.txt` within the NetBox root directory.
+
+### NAPALM
+
+The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
+
+```no-highlight
+# echo napalm >> /opt/netbox/local_requirements.txt
+```
+
+### Remote File Storage
+
+By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`.
+
+```no-highlight
+# echo django-storages >> /opt/netbox/local_requirements.txt
+```
+
+## Run the Upgrade Script
+
+Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:
+
+* Create a Python virtual environment
+* Install all required Python packages
+* Run database schema migrations
+* Aggregate static resource files on disk
+
+```no-highlight
+# /opt/netbox/upgrade.sh
+```
+
+!!! note
+ Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
## Create a Super User
-NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
+NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:
```no-highlight
+# source /opt/netbox/venv/bin/activate
+```
+
+Once the virtual environment has been activated, you should notice the string `(venv)` prepended to your console prompt.
+
+Next, we'll create a superuser account using the `createsuperuser` Django management command (via `manage.py`). Specifying an email address for the user is not required, but be sure to use a very strong password.
+
+```no-highlight
+(venv) # cd /opt/netbox/netbox
(venv) # python3 manage.py createsuperuser
Username: admin
Email address: admin@example.com
@@ -241,17 +250,9 @@ Password (again):
Superuser created successfully.
```
-## Collect Static Files
-
-```no-highlight
-(venv) # python3 manage.py collectstatic --no-input
-
-959 static files copied to '/opt/netbox/netbox/static'.
-```
-
## Test the Application
-At this point, NetBox should be able to run. We can verify this by starting a development instance:
+At this point, we should be able to run NetBox. We can check by starting a development instance:
```no-highlight
(venv) # python3 manage.py runserver 0.0.0.0:8000 --insecure
@@ -273,6 +274,6 @@ Note that the initial UI will be locked down for non-authenticated users.

-After logging in as the superuser you created earlier, all areas of the UI will be available.
+Try logging in as the super user we just created. Once authenticated, you'll be able to access all areas of the UI:

diff --git a/docs/installation/4-gunicorn.md b/docs/installation/4-gunicorn.md
new file mode 100644
index 000000000..48cab756a
--- /dev/null
+++ b/docs/installation/4-gunicorn.md
@@ -0,0 +1,49 @@
+# Gunicorn
+
+Like most Django applications, NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) for this role, however other WSGIs are available and should work similarly well.
+
+## Configuration
+
+NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any changes to it do not get overwritten by a future upgrade.)
+
+```no-highlight
+# cd /opt/netbox
+# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
+```
+
+While this default configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
+
+## systemd Setup
+
+We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
+
+```no-highlight
+# cp contrib/*.service /etc/systemd/system/
+# systemctl daemon-reload
+```
+
+Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
+
+```no-highlight
+# systemctl start netbox netbox-rq
+# systemctl enable netbox netbox-rq
+```
+
+You can use the command `systemctl status netbox` to verify that the WSGI service is running:
+
+```no-highlight
+# systemctl status netbox.service
+● netbox.service - NetBox WSGI Service
+ Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
+ Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
+ Docs: https://netbox.readthedocs.io/en/stable/
+ Main PID: 11993 (gunicorn)
+ Tasks: 6 (limit: 2362)
+ CGroup: /system.slice/netbox.service
+ ├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
+ ├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
+ ├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
+...
+```
+
+Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.
diff --git a/docs/installation/4-http-daemon.md b/docs/installation/5-http-server.md
similarity index 51%
rename from docs/installation/4-http-daemon.md
rename to docs/installation/5-http-server.md
index b93bdc3ef..5b4e3dfae 100644
--- a/docs/installation/4-http-daemon.md
+++ b/docs/installation/5-http-server.md
@@ -1,9 +1,9 @@
# HTTP Server Setup
-We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll use systemd to enable service persistence.
+This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4), though any HTTP server which supports WSGI should be compatible.
!!! info
- For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
+ For the sake of brevity, only Ubuntu 18.04 instructions are provided here, these tasks not unique to NetBox and should carry over to other distributions with mininal changes. Please consult your distribution's documentation for assistance if needed.
## Obtain an SSL Certificate
@@ -17,17 +17,19 @@ The command below can be used to generate a self-signed certificate for testing
-out /etc/ssl/certs/netbox.crt
```
-## HTTP Daemon Installation
+The above command will prompt you for additional details of the certificate; all of these are optional.
+
+## HTTP Server Installation
### Option A: nginx
-The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
+Begin by installing nginx:
```no-highlight
# apt-get install -y nginx
```
-Once nginx is installed, copy the default nginx configuration file to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
+Once nginx is installed, copy the nginx configuration file provided by NetBox to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
```no-highlight
# cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox
@@ -69,67 +71,25 @@ Finally, ensure that the required Apache modules are enabled, enable the `netbox
# service apache2 restart
```
-!!! note
- Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox.
-
-## Gunicorn Configuration
-
-Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.)
-
-```no-highlight
-# cd /opt/netbox
-# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
-```
-
-You may wish to edit this file to change the bound IP address or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
-
-## systemd Configuration
-
-We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory:
-
-```no-highlight
-# cp contrib/*.service /etc/systemd/system/
-```
-
-Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
-
-```no-highlight
-# systemctl daemon-reload
-# systemctl start netbox netbox-rq
-# systemctl enable netbox netbox-rq
-```
-
-You can use the command `systemctl status netbox` to verify that the WSGI service is running:
-
-```no-highlight
-# systemctl status netbox.service
-● netbox.service - NetBox WSGI Service
- Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
- Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
- Docs: https://netbox.readthedocs.io/en/stable/
- Main PID: 11993 (gunicorn)
- Tasks: 6 (limit: 2362)
- CGroup: /system.slice/netbox.service
- ├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
- ├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
- ├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
-...
-```
+## Confirm Connectivity
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided.
!!! info
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.
+!!! warning
+ Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox.
+
## Troubleshooting
If you are unable to connect to the HTTP server, check that:
* Nginx/Apache is running and configured to listen on the correct port.
-* Access is not being blocked by a firewall. (Try connecting locally from the server itself.)
+* Access is not being blocked by a firewall somewhere along the path. (Try connecting locally from the server itself.)
If you are able to connect but receive a 502 (bad gateway) error, check the following:
-* The NetBox system process (gunicorn) is running: `systemctl status netbox`
+* The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)")
* nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
* SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`
diff --git a/docs/installation/5-ldap.md b/docs/installation/6-ldap.md
similarity index 89%
rename from docs/installation/5-ldap.md
rename to docs/installation/6-ldap.md
index 2fd88b841..bb1300c08 100644
--- a/docs/installation/5-ldap.md
+++ b/docs/installation/6-ldap.md
@@ -36,7 +36,13 @@ Once installed, add the package to `local_requirements.txt` to ensure it is re-i
## Configuration
-Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
+First, enable the LDAP authentication backend in `configuration.py`. (Be sure to overwrite this definition if it is already set to `RemoteUserBackend`.)
+
+```python
+REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
+```
+
+Next, create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
### General Server Configuration
@@ -145,7 +151,8 @@ logfile = "/opt/netbox/logs/django-ldap-debug.log"
my_logger = logging.getLogger('django_auth_ldap')
my_logger.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(
- logfile, maxBytes=1024 * 500, backupCount=5)
+ logfile, maxBytes=1024 * 500, backupCount=5
+)
my_logger.addHandler(handler)
```
diff --git a/docs/installation/index.md b/docs/installation/index.md
index 4c904e953..b520f2917 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -5,8 +5,9 @@ The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)
1. [Redis](2-redis.md)
3. [NetBox components](3-netbox.md)
-4. [HTTP daemon](4-http-daemon.md)
-5. [LDAP authentication](5-ldap.md) (optional)
+4. [Gunicorn](4-gunicorn.md)
+5. [HTTP server](5-http-server.md)
+6. [LDAP authentication](6-ldap.md) (optional)
Below is a simplified overview of the NetBox application stack for reference:
@@ -16,4 +17,5 @@ Below is a simplified overview of the NetBox application stack for reference:
If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md).
-Netbox v2.5.9 and later moved to using systemd instead of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.
+!!! note
+ Beginning with v2.5.9, the official documentation calls for systemd to be used for managing the WSGI workers in place of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.
diff --git a/docs/media/models/dcim_cable_trace.png b/docs/media/models/dcim_cable_trace.png
new file mode 100644
index 000000000..9597c8828
Binary files /dev/null and b/docs/media/models/dcim_cable_trace.png differ
diff --git a/docs/media/power_distribution.png b/docs/media/power_distribution.png
new file mode 100644
index 000000000..48c1e5f4f
Binary files /dev/null and b/docs/media/power_distribution.png differ
diff --git a/docs/models/circuits/circuit.md b/docs/models/circuits/circuit.md
index 47320495d..9421f94fb 100644
--- a/docs/models/circuits/circuit.md
+++ b/docs/models/circuits/circuit.md
@@ -1,3 +1,19 @@
# Circuits
-A circuit represents a single _physical_ link connecting exactly two endpoints. (A circuit with more than two endpoints is a virtual circuit, which is not currently supported by NetBox.) Each circuit belongs to a provider and must be assigned a circuit ID which is unique to that provider.
+A communications circuit represents a single _physical_ link connecting exactly two endpoints, commonly referred to as its A and Z terminations. A circuit in NetBox may have zero, one, or two terminations defined. It is common to have only one termination defined when you don't necessarily care about the details of the provider side of the circuit, e.g. for Internet access circuits. Both terminations would likely be modeled for circuits which connect one customer site to another.
+
+Each circuit is associated with a provider and a user-defined type. For example, you might have Internet access circuits delivered to each site by one provider, and private MPLS circuits delivered by another. Each circuit must be assigned a circuit ID, each of which must be unique per provider.
+
+Each circuit is also assigned one of the following operational statuses:
+
+* Planned
+* Provisioning
+* Active
+* Offline
+* Deprovisioning
+* Decommissioned
+
+Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants.
+
+!!! note
+ NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling.
diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md
index a39236314..1c0dbfe18 100644
--- a/docs/models/circuits/circuittermination.md
+++ b/docs/models/circuits/circuittermination.md
@@ -1,11 +1,10 @@
# Circuit Terminations
-A circuit may have one or two terminations, annotated as the "A" and "Z" sides of the circuit. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites.
+The association of a circuit with a particular site and/or device is modeled separately as a circuit termination. A circuit may have up to two terminations, labeled A and Z. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites.
-Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or pass-through port. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details.
+Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details.
+
+In adherence with NetBox's philosophy of closely modeling the real world, a circuit may terminate only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely.
!!! note
- A circuit represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit.
-
-!!! note
- A circuit may terminate only to a physical interface. Circuits may not terminate to LAG interfaces, which are virtual interfaces: You must define each physical circuit within a service bundle separately and terminate it to its actual physical interface.
+ A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure.
diff --git a/docs/models/circuits/circuittype.md b/docs/models/circuits/circuittype.md
index a9ae117b8..aa8258e04 100644
--- a/docs/models/circuits/circuittype.md
+++ b/docs/models/circuits/circuittype.md
@@ -1,10 +1,8 @@
# Circuit Types
-Circuits are classified by type. For example, you might define circuit types for:
+Circuits are classified by functional type. These types are completely customizable, and are typically used to convey the type of service being delivered over a circuit. For example, you might define circuit types for:
* Internet transit
* Out-of-band connectivity
* Peering
* Private backhaul
-
-Circuit types are fully customizable.
\ No newline at end of file
diff --git a/docs/models/circuits/provider.md b/docs/models/circuits/provider.md
index c8e19f6cd..e0847b72f 100644
--- a/docs/models/circuits/provider.md
+++ b/docs/models/circuits/provider.md
@@ -1,5 +1,5 @@
# Providers
-A provider is any entity which provides some form of connectivity. While this obviously includes carriers which offer Internet and private transit service, it might also include Internet exchange (IX) points and even organizations with whom you peer directly.
+A circuit provider is any entity which provides some form of connectivity of among sites or organizations within a site. While this obviously includes carriers which offer Internet and private transit service, it might also include Internet exchange (IX) points and even organizations with whom you peer directly. Each circuit within NetBox must be assigned a provider and a circuit ID which is unique to that provider.
-Each provider may be assigned an autonomous system number (ASN), an account number, and relevant contact information.
+Each provider may be assigned an autonomous system number (ASN), an account number, and contact information.
diff --git a/docs/models/dcim/cable.md b/docs/models/dcim/cable.md
index 8b00a999d..753ab6f7f 100644
--- a/docs/models/dcim/cable.md
+++ b/docs/models/dcim/cable.md
@@ -1,19 +1,34 @@
# Cables
-A cable represents a physical connection between two termination points, such as between a console port and a patch panel port, or between two network interfaces. Cables can be traced through pass-through ports to form a complete path between two endpoints. In the example below, three individual cables comprise a path between the two connected endpoints.
+All connections between device components in NetBox are represented using cables. A cable represents a direct physical connection between two termination points, such as between a console port and a patch panel port, or between two network interfaces.
-```
-|<------------------------------------------ Cable Path ------------------------------------------->|
+Each cable must have two endpoints defined. These endpoints are sometimes referenced as A and B for clarity, however cables are direction-agnostic and the order in which terminations are made has no meaning. Cables may be connected to the following objects:
- Device A Patch Panel A Patch Panel B Device B
-+-----------+ +-------------+ +-------------+ +-----------+
-| Interface | --- Cable --- | Front Port | | Front Port | --- Cable --- | Interface |
-+-----------+ +-------------+ +-------------+ +-----------+
- +-------------+ +-------------+
- | Rear Port | --- Cable --- | Rear Port |
- +-------------+ +-------------+
-```
+* Circuit terminations
+* Console ports
+* Console server ports
+* Interfaces
+* Pass-through ports (front and rear)
+* Power feeds
+* Power outlets
+* Power ports
-All connections between device components in NetBox are represented using cables. However, defining the actual cable plant is optional: Components can be be directly connected using cables with no type or other attributes assigned.
+Each cable may be assigned a type, label, length, and color. Each cable is also assigned one of three operational statuses:
-Cables are also used to associated ports and interfaces with circuit terminations. To do this, first create the circuit termination, then navigate the desired component and connect a cable between the two.
+* Active (default)
+* Planned
+* Decommissioning
+
+## Tracing Cables
+
+A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user.
+
+In the example below, three individual cables comprise a path between devices A and D:
+
+
+
+Traced from Interface 1 on Device A, NetBox will show the following path:
+
+* Cable 1: Interface 1 to Front Port 1
+* Cable 2: Rear Port 1 to Rear Port 2
+* Cable 3: Front Port 2 to Interface 2
diff --git a/docs/models/dcim/consoleport.md b/docs/models/dcim/consoleport.md
index 4d3a089c5..1a0782f25 100644
--- a/docs/models/dcim/consoleport.md
+++ b/docs/models/dcim/consoleport.md
@@ -1,5 +1,5 @@
## Console Ports
-A console port provides connectivity to the physical console of a device. Console ports are typically used for temporary access by someone who is physically near the device, or for remote out-of-band access via a console server.
+A console port provides connectivity to the physical console of a device. These are typically used for temporary access by someone who is physically near the device, or for remote out-of-band access provided via a networked console server. Each console port may be assigned a physical type.
-Console ports can be connected to console server ports.
+Cables can connect console ports to console server ports or pass-through ports.
diff --git a/docs/models/dcim/consoleporttemplate.md b/docs/models/dcim/consoleporttemplate.md
index 86281cb92..3462ff253 100644
--- a/docs/models/dcim/consoleporttemplate.md
+++ b/docs/models/dcim/consoleporttemplate.md
@@ -1,3 +1,3 @@
## Console Port Templates
-A template for a console port that will be created on all instantiations of the parent device type.
+A template for a console port that will be created on all instantiations of the parent device type. Each console port can be assigned a physical type.
diff --git a/docs/models/dcim/consoleserverport.md b/docs/models/dcim/consoleserverport.md
index 55aefd733..da1ee8986 100644
--- a/docs/models/dcim/consoleserverport.md
+++ b/docs/models/dcim/consoleserverport.md
@@ -1,5 +1,5 @@
## Console Server Ports
-A console server is a device which provides remote access to the local consoles of connected devices. This is typically done to provide remote out-of-band access to network devices.
+A console server is a device which provides remote access to the local consoles of connected devices. They are typically used to provide remote out-of-band access to network devices. Each console server port may be assigned a physical type.
-Console server ports can be connected to console ports.
+Cables can connect console server ports to console ports or pass-through ports.
diff --git a/docs/models/dcim/consoleserverporttemplate.md b/docs/models/dcim/consoleserverporttemplate.md
index ed99adb11..cc4e8bcd3 100644
--- a/docs/models/dcim/consoleserverporttemplate.md
+++ b/docs/models/dcim/consoleserverporttemplate.md
@@ -1,3 +1,3 @@
## Console Server Port Templates
-A template for a console server port that will be created on all instantiations of the parent device type.
+A template for a console server port that will be created on all instantiations of the parent device type. Each console server port can be assigned a physical type.
diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md
index 9ec2875da..df14c0e07 100644
--- a/docs/models/dcim/device.md
+++ b/docs/models/dcim/device.md
@@ -1,7 +1,15 @@
# Devices
-Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and can be half depth or full depth. A device may have a height of 0U: These devices do not consume vertical rack space and cannot be assigned to a particular rack unit. A common example of a 0U device is a vertically-mounted PDU.
+Every piece of hardware which is installed within a site or rack exists in NetBox as a device. Devices are measured in rack units (U) and can be half depth or full depth. A device may have a height of 0U: These devices do not consume vertical rack space and cannot be assigned to a particular rack unit. A common example of a 0U device is a vertically-mounted PDU.
When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 is said to be mounted in U8. This logic applies to racks with both ascending and descending unit numbering.
-A device is said to be full depth if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede airflow.
+A device is said to be full-depth if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede airflow.
+
+Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.)
+
+Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device.
+
+Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed.
+
+When a device has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6.
diff --git a/docs/models/dcim/devicebay.md b/docs/models/dcim/devicebay.md
index cdcd5657d..2aea14a7a 100644
--- a/docs/models/dcim/devicebay.md
+++ b/docs/models/dcim/devicebay.md
@@ -1,7 +1,8 @@
## Device Bays
-Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations or the "Non-Racked Devices" list within the rack view.
+Device bays represent a space or slot within a parent device in which a child device may be installed. For example, a 2U parent chassis might house four individual blade servers. The chassis would appear in the rack elevation as a 2U device with four device bays, and each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations or count as consuming rack units.
-Child devices are first-class Devices in their own right: that is, fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and interfaces. You cannot create a LAG between interfaces in different child devices.
+Child devices are first-class Devices in their own right: That is, they are fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and components. LAG interfaces may not group interfaces belonging to different child devices.
-Therefore, Device bays are **not** suitable for modeling chassis-based switches and routers. These should instead be modeled as a single Device, with the line cards as Inventory Items.
+!!! note
+ Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device.
diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md
index 315f81356..13b8f021e 100644
--- a/docs/models/dcim/devicerole.md
+++ b/docs/models/dcim/devicerole.md
@@ -1,3 +1,3 @@
# Device Roles
-Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches.
+Devices can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for core switches, distribution switches, and access switches within your network.
diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md
index 1a10cee41..a7e00dbc6 100644
--- a/docs/models/dcim/devicetype.md
+++ b/docs/models/dcim/devicetype.md
@@ -1,18 +1,14 @@
# Device Types
-A device type represents a particular make and model of hardware that exists in the real world. Device types define the physical attributes of a device (rack height and depth) and its individual components (console, power, and network interfaces).
+A device type represents a particular make and model of hardware that exists in the real world. Device types define the physical attributes of a device (rack height and depth) and its individual components (console, power, network interfaces, and so on).
-Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type at the time of creation. (However, changes made to a device type will **not** apply to instances of that device type retroactively.)
+Device types are instantiated as devices installed within sites and/or equipment racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple _instances_ of this type named "switch1," "switch2," and so on. Each device will automatically inherit the components (such as interfaces) of its device type at the time of creation. However, changes made to a device type will **not** apply to instances of that device type retroactively.
Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following:
* A parent device (which has device bays)
-* A child device (which must be installed in a device bay)
+* A child device (which must be installed within a device bay)
* Neither
!!! note
- This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane.
-
- For that application you should create a single Device for the chassis, and add Interfaces directly to it. Interfaces can be created in bulk using range patterns, e.g. "Gi1/[1-24]".
-
- Add Inventory Items if you want to record the line cards themselves as separate entities. There is no explicit relationship between each interface and its line card, but it may be implied by the naming (e.g. interfaces "Gi1/x" are on line card 1)
+ This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device.
diff --git a/docs/models/dcim/frontport.md b/docs/models/dcim/frontport.md
index 12b9cfc16..0b753c012 100644
--- a/docs/models/dcim/frontport.md
+++ b/docs/models/dcim/frontport.md
@@ -1,5 +1,3 @@
## Front Ports
-Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports.
-
-Each front port is mapped to a specific rear port on the same device. A single rear port may be mapped to multiple rear ports.
\ No newline at end of file
+Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific rear port on the same device. A single rear port may be mapped to multiple rear ports, using numeric positions to annotate the specific alignment of each.
diff --git a/docs/models/dcim/frontporttemplate.md b/docs/models/dcim/frontporttemplate.md
index b32349519..03de0eae4 100644
--- a/docs/models/dcim/frontporttemplate.md
+++ b/docs/models/dcim/frontporttemplate.md
@@ -1,3 +1,3 @@
## Front Port Templates
-A template for a front-facing pass-through port that will be created on all instantiations of the parent device type.
+A template for a front-facing pass-through port that will be created on all instantiations of the parent device type. Front ports may have a physical type assigned, and must be associated with a corresponding rear port and position. This association will be automatically replicated when the device type is instantiated.
diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md
index cbccbec8d..be43ac2a6 100644
--- a/docs/models/dcim/interface.md
+++ b/docs/models/dcim/interface.md
@@ -1,9 +1,12 @@
## Interfaces
-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 in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management).
-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.
+Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces.
-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.
+Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. Like all virtual interfaces, LAG interfaces cannot be connected physically.
-VLANs can be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
+IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
+
+!!! note
+ Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa.
diff --git a/docs/models/dcim/interfacetemplate.md b/docs/models/dcim/interfacetemplate.md
index 07fc3a65b..d9b30dd87 100644
--- a/docs/models/dcim/interfacetemplate.md
+++ b/docs/models/dcim/interfacetemplate.md
@@ -1,3 +1,3 @@
## Interface Templates
-A template for an interface that will be created on all instantiations of the parent device type.
+A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only."
diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md
index b113dce1e..237bad92c 100644
--- a/docs/models/dcim/inventoryitem.md
+++ b/docs/models/dcim/inventoryitem.md
@@ -1,3 +1,7 @@
# Inventory Items
-Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Like device types, each item can optionally be assigned a manufacturer.
+Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes.
+
+Each inventory item can be assigned a manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox).
+
+Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device.
diff --git a/docs/models/dcim/manufacturer.md b/docs/models/dcim/manufacturer.md
index cee89291d..df227ee17 100644
--- a/docs/models/dcim/manufacturer.md
+++ b/docs/models/dcim/manufacturer.md
@@ -1,3 +1,3 @@
# Manufacturers
-Each device type must be assigned to a manufacturer. The model number of a device type must be unique to its manufacturer.
+A manufacturer represents the "make" of a device; e.g. Cisco or Dell. Each device type must be assigned to a manufacturer. (Inventory items and platforms may also be associated with manufacturers.) Each manufacturer must have a unique name and may have a description assigned to it.
diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md
index 19528da13..a860904b5 100644
--- a/docs/models/dcim/platform.md
+++ b/docs/models/dcim/platform.md
@@ -1,7 +1,9 @@
# Platforms
-A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of the same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
+A platform defines the type of software running on a device or virtual machine. This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15.
-The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
+Platforms may optionally be limited by manufacturer: If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
+
+The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
diff --git a/docs/models/dcim/powerfeed.md b/docs/models/dcim/powerfeed.md
index 690e755d7..48ad2a5dc 100644
--- a/docs/models/dcim/powerfeed.md
+++ b/docs/models/dcim/powerfeed.md
@@ -1,8 +1,21 @@
# Power Feed
-A power feed identifies the power outlet/drop that goes to a rack and is terminated to a power panel. Power feeds have a supply type (AC/DC), voltage, amperage, and phase type (single/three).
+A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power pot (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks.
-Power feeds are optionally assigned to a rack. In addition, a power port may be connected to a power feed. In the context of a PDU, the power feed is analogous to the power outlet that a PDU's power port/inlet connects to.
+Each power feed is assigned an operational type (primary or redundant) and one of the following statuses:
+
+* Offline
+* Active
+* Planned
+* Failed
+
+Each power feed also defines the electrical characteristics of the circuit which it represents. These include the following:
+
+* Supply type (AC or DC)
+* Phase (single or three-phase)
+* Voltage
+* Amperage
+* Maximum utilization (percentage)
!!! info
- The power usage of a rack is calculated when a power feed (or multiple) is assigned to that rack and connected to a power port.
+ The power utilization of a rack is calculated when one or more power feeds are assigned to the rack and connected to devices that draw power.
diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md
index 0ec93856e..e9ef307bd 100644
--- a/docs/models/dcim/poweroutlet.md
+++ b/docs/models/dcim/poweroutlet.md
@@ -1,3 +1,7 @@
## Power Outlets
-Power outlets represent the ports on a PDU that supply power to other devices. Power outlets are downstream-facing towards power ports. A power outlet can be associated with a power port on the same device and a feed leg (i.e. in a case of a three-phase supply). This indicates which power port supplies power to a power outlet.
+Power outlets represent the outlets on a power distribution unit (PDU) or other device that supply power to dependent devices. Each power port may be assigned a physical type, and may be associated with a specific feed leg (where three-phase power is used) and/or a specific upstream power port. This association can be used to model the distribution of power within a device.
+
+For example, imagine a PDU with one power port which draws from a three-phase feed and 48 power outlets arranged into three banks of 16 outlets each. Outlets 1-16 would be associated with leg A on the port, and outlets 17-32 and 33-48 would be associated with legs B and C, respectively.
+
+Cables can connect power outlets only to downstream power ports. (Pass-through ports cannot be used to model power distribution.)
diff --git a/docs/models/dcim/poweroutlettemplate.md b/docs/models/dcim/poweroutlettemplate.md
index e5b54af23..6f81891f1 100644
--- a/docs/models/dcim/poweroutlettemplate.md
+++ b/docs/models/dcim/poweroutlettemplate.md
@@ -1,3 +1,3 @@
## Power Outlet Templates
-A template for a power outlet that will be created on all instantiations of the parent device type.
+A template for a power outlet that will be created on all instantiations of the parent device type. Each power outlet can be assigned a physical type, and its power source may be mapped to a specific feed leg and power port template. This association will be automatically replicated when the device type is instantiated.
diff --git a/docs/models/dcim/powerpanel.md b/docs/models/dcim/powerpanel.md
index 3b05f8fad..3daecbacf 100644
--- a/docs/models/dcim/powerpanel.md
+++ b/docs/models/dcim/powerpanel.md
@@ -1,3 +1,8 @@
# Power Panel
-A power panel represents the distribution board where power circuits – and their circuit breakers – terminate on. If you have multiple power panels in your data center, you should model them as such in NetBox to assist you in determining the redundancy of your power allocation.
+A power panel represents the origin point in NetBox for electrical power being disseminated by one or more power feeds. In a data center environment, one power panel often serves a group of racks, with an individual power feed extending to each rack, though this is not always the case. It is common to have two sets of panels and feeds arranged in parallel to provide redundant power to each rack.
+
+Each power panel must be assigned to a site, and may optionally be assigned to a particular rack group.
+
+!!! note
+ NetBox does not model the mechanism by which power is delivered to a power panel. Power panels define the root level of the power distribution hierarchy in NetBox.
diff --git a/docs/models/dcim/powerport.md b/docs/models/dcim/powerport.md
index 6027fa98b..1948920d0 100644
--- a/docs/models/dcim/powerport.md
+++ b/docs/models/dcim/powerport.md
@@ -1,6 +1,8 @@
## Power Ports
-A power port is the inlet of a device where it draws its power. Power ports are upstream-facing towards power outlets. Alternatively, a power port can connect to a power feed – as mentioned in the power feed section – to indicate the power source of a PDU's inlet.
+A power port represents the inlet of a device where it draws its power, i.e. the connection port(s) on a device's power supply. Each power port may be assigned a physical type, as well as allocated and maximum draw values (in watts). These values can be used to calculate the overall utilization of an upstream power feed.
!!! info
- If the draw of a power port is left empty, it will be dynamically calculated based on the power outlets associated with that power port. This is usually the case on the power ports of devices that supply power, like a PDU.
+ When creating a power port on a device which supplies power to downstream devices, the allocated and maximum draw numbers should be left blank. Utilization will be calculated by taking the sum of all power ports of devices connected downstream.
+
+Cables can connect power ports only to power outlets or power feeds. (Pass-through ports cannot be used to model power distribution.)
diff --git a/docs/models/dcim/powerporttemplate.md b/docs/models/dcim/powerporttemplate.md
index b6e64be01..947f146ae 100644
--- a/docs/models/dcim/powerporttemplate.md
+++ b/docs/models/dcim/powerporttemplate.md
@@ -1,3 +1,3 @@
## Power Port Templates
-A template for a power port that will be created on all instantiations of the parent device type.
+A template for a power port that will be created on all instantiations of the parent device type. Each power port can be assigned a physical type, as well as a maximum and allocated draw in watts.
diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md
index 39858b823..e5e52cc07 100644
--- a/docs/models/dcim/rack.md
+++ b/docs/models/dcim/rack.md
@@ -1,8 +1,10 @@
# Racks
-The rack model represents a physical two- or four-post equipment rack in which equipment is mounted. Each rack must be assigned to a site. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending or descending order.
+The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a rack group and/or tenant. Racks can also be organized by user-defined functional roles.
-Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." A unique serial number may also be associated with each rack.
+Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order.
+
+Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." A unique serial number and asset tag may also be associated with each rack.
A rack must be designated as one of the following types:
@@ -12,4 +14,12 @@ A rack must be designated as one of the following types:
* Wall-mounted frame
* Wall-mounted cabinet
-Each rack has two faces (front and rear) on which devices can be mounted. Rail-to-rail width may be 19 or 23 inches.
+Similarly, each rack must be assigned an operational status, which is one of the following:
+
+* Reserved
+* Available
+* Planned
+* Active
+* Deprecated
+
+Each rack has two faces (front and rear) on which devices can be mounted. Rail-to-rail width may be 10, 19, 21, or 23 inches. The outer width and depth of a rack or cabinet can also be annotated in millimeters or inches.
diff --git a/docs/models/dcim/rackgroup.md b/docs/models/dcim/rackgroup.md
index f5b2428e6..974285f71 100644
--- a/docs/models/dcim/rackgroup.md
+++ b/docs/models/dcim/rackgroup.md
@@ -1,7 +1,7 @@
# Rack Groups
-Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room.
+Racks can be organized into groups, which can be nested into themselves similar to regions. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room.
-Each rack group must be assigned to a parent site, and rack groups may optionally be nested to achieve a multi-level hierarchy.
+Each rack group must be assigned to a parent site, and rack groups may optionally be nested within a site to model a multi-level hierarchy. For example, you might have a tier of rooms beneath a tier of floors, all belonging to the same parent building (site).
The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.)
diff --git a/docs/models/dcim/rackreservation.md b/docs/models/dcim/rackreservation.md
index 09de55553..0ed9651a0 100644
--- a/docs/models/dcim/rackreservation.md
+++ b/docs/models/dcim/rackreservation.md
@@ -1,3 +1,3 @@
# Rack Reservations
-Users can reserve units within a rack for future use. Multiple non-contiguous rack units can be associated with a single reservation (but reservations cannot span multiple racks). A rack reservation may optionally designate a specific tenant.
+Users can reserve specific units within a rack for future use. An arbitrary set of units within a rack can be associated with a single reservation, but reservations cannot span multiple racks. A description is required for each reservation, reservations may optionally be associated with a specific tenant.
diff --git a/docs/models/dcim/rackrole.md b/docs/models/dcim/rackrole.md
index 63e9c1469..1375ce692 100644
--- a/docs/models/dcim/rackrole.md
+++ b/docs/models/dcim/rackrole.md
@@ -1,3 +1,3 @@
# Rack Roles
-Each rack can optionally be assigned a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable.
+Each rack can optionally be assigned a user-defined functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable and may be color-coded.
diff --git a/docs/models/dcim/rearport.md b/docs/models/dcim/rearport.md
index 8c8136338..41c5b3037 100644
--- a/docs/models/dcim/rearport.md
+++ b/docs/models/dcim/rearport.md
@@ -1,5 +1,6 @@
## Rear Ports
-Like front ports, rear ports are pass-through ports which represent the end of a particular cable segment in a path. Each rear port is defined with a number of positions: rear ports with more than one position can be mapped to multiple front ports. This can be useful for modeling instances where multiple paths share a common cable (for example, six different fiber connections sharing a 12-strand MPO cable).
+Like front ports, rear ports are pass-through ports which represent the continuation of a path from one cable to the next. Each rear port is defined with its physical type and a number of positions: Rear ports with more than one position can be mapped to multiple front ports. This can be useful for modeling instances where multiple paths share a common cable (for example, six discrete two-strand fiber connections sharing a 12-strand MPO cable).
-Note that front and rear ports need not necessarily reside on the actual front or rear device face. This terminology is used primarily to distinguish between the two components in a pass-through port pairing.
+!!! note
+ Front and rear ports need not necessarily reside on the actual front or rear device face. This terminology is used primarily to distinguish between the two components in a pass-through port pairing.
diff --git a/docs/models/dcim/rearporttemplate.md b/docs/models/dcim/rearporttemplate.md
index 448c0befd..71d9a200b 100644
--- a/docs/models/dcim/rearporttemplate.md
+++ b/docs/models/dcim/rearporttemplate.md
@@ -1,3 +1,3 @@
## Rear Port Templates
-A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type.
+A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 64).
diff --git a/docs/models/dcim/site.md b/docs/models/dcim/site.md
index b13056a99..6617b950c 100644
--- a/docs/models/dcim/site.md
+++ b/docs/models/dcim/site.md
@@ -1,13 +1,15 @@
# Sites
-How you choose to use sites will depend on the nature of your organization, but typically a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities.
+How you choose to employ sites when modeling your network may vary depending on the nature of your organization, but generally a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities.
-Each site must be assigned one of the following operational statuses:
+Each site must be assigned a unique name and may optionally be assigned to a region and/or tenant. The following operational statuses are available:
-* Active
* Planned
+* Staging
+* Active
+* Decommissioning
* Retired
-The site model provides a facility ID field which can be used to annotate a facility ID (such as a datacenter name) associated with the site. Each site may also have an autonomous system (AS) number and time zone associated with it. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.)
+The site model also provides a facility ID field which can be used to annotate a facility ID (such as a datacenter name) associated with the site. Each site may also have an autonomous system (AS) number and time zone associated with it. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.)
-The site model also includes several fields for storing contact and address information.
+The site model also includes several fields for storing contact and address information as well as geolocation data (GPS coordinates).
diff --git a/docs/models/dcim/virtualchassis.md b/docs/models/dcim/virtualchassis.md
index e1707918b..b2a7d3bc9 100644
--- a/docs/models/dcim/virtualchassis.md
+++ b/docs/models/dcim/virtualchassis.md
@@ -1,5 +1,8 @@
# Virtual Chassis
-A virtual chassis represents a set of devices which share a single control plane: a stack of switches which are managed as a single device, for example. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management.
+A virtual chassis represents a set of devices which share a common control plane. A common example of this is a stack of switches which are connected and configured to operate as a single device. A virtual chassis must be assigned a name and may be assigned a domain.
-It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently.
+Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, secrets, services, and other attributes related to managing the VC.
+
+!!! note
+ It's important to recognize the distinction between a virtual chassis and a chassis-based device. A virtual chassis is **not** suitable for modeling a chassis-based switch with removable line cards (such as the Juniper EX9208), as its line cards are _not_ physically autonomous devices.
diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md
index 380e631d8..af81cfbf9 100644
--- a/docs/models/extras/configcontext.md
+++ b/docs/models/extras/configcontext.md
@@ -1,5 +1,65 @@
# Configuration Contexts
-Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object.
+Sometimes it is desirable to associate additional data with a group of devices or virtual machines to aid in automated configuration. For example, you might want to associate a set of syslog servers for all devices within a particular region. Context data enables the association of extra user-defined data with devices and virtual machines grouped by one or more of the following assignments:
-Devices and Virtual Machines may also have a local config context defined. This local context will always overwrite the rendered config context objects for the Device/VM. This is useful in situations were the device requires a one-off value different from the rest of the environment.
+* Region
+* Site
+* Role
+* Platform
+* Cluster group
+* Cluster
+* Tenant group
+* Tenant
+* Tag
+
+## Hierarchical Rendering
+
+Context data is arranged hierarchically, so that data with a higher weight can be entered to override lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object.
+
+For example, suppose we want to specify a set of syslog and NTP servers for all devices within a region. We could create a config context instance with a weight of 1000 assigned to the region, with the following JSON data:
+
+```json
+{
+ "ntp-servers": [
+ "172.16.10.22",
+ "172.16.10.33"
+ ],
+ "syslog-servers": [
+ "172.16.9.100",
+ "172.16.9.101"
+ ]
+}
+```
+
+But suppose there's a problem at one particular site within this region preventing traffic from reaching the regional syslog server. Devices there need to use a local syslog server instead of the two defined above. We'll create a second config context assigned only to that site with a weight of 2000 and the following data:
+
+```json
+{
+ "syslog-servers": [
+ "192.168.43.107"
+ ]
+}
+```
+
+When the context data for a device at this site is rendered, the second, higher-weight data overwrite the first, resulting in the following:
+
+```json
+{
+ "ntp-servers": [
+ "172.16.10.22",
+ "172.16.10.33"
+ ],
+ "syslog-servers": [
+ "192.168.43.107"
+ ]
+}
+```
+
+Data from the higher-weight context overwrites conflicting data from the lower-weight context, while the non-conflicting portion of the lower-weight context (the list of NTP servers) is preserved.
+
+## Local Context Data
+
+Devices and virtual machines may also have a local config context defined. This local context will _always_ take precedence over any separate config context objects which apply to the device/VM. This is useful in situations where we need to call out a specific deviation in the data for a particular object.
+
+!!! warning
+ If you find that you're routinely defining local context data for many individual devices or virtual machines, custom fields may offer a more effective solution.
diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md
index f94957616..29cc8b757 100644
--- a/docs/models/extras/tag.md
+++ b/docs/models/extras/tag.md
@@ -1,24 +1,20 @@
# Tags
-Tags are free-form text labels which can be applied to a variety of objects within NetBox. Tags are created on-demand as they are assigned to objects. Use commas to separate tags when adding multiple tags to an object (for example: `Inventoried, Monitored`). Use double quotes around a multi-word tag when adding only one tag, e.g. `"Core Switch"`.
+Tags are user-defined labels which can be applied to a variety of objects within NetBox. They can be used to establish dimensions of organization beyond the relationships built into NetBox. For example, you might create a tag to identify a particular ownership or condition across several types of objects.
-Each tag has a label and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes tags easier to work with as URL parameters.
+Each tag has a label, color, and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes tags easier to work with as URL parameters. Each tag can also be assigned a description indicating its purpose.
Objects can be filtered by the tags they have applied. For example, the following API request will retrieve all devices tagged as "monitored":
-```
+```no-highlight
GET /api/dcim/devices/?tag=monitored
```
-Tags are included in the API representation of an object as a list of plain strings:
+The `tag` filter can be specified multiple times to match only objects which have _all_ of the specified tags assigned:
+```no-highlight
+GET /api/dcim/devices/?tag=monitored&tag=deprecated
```
-{
- ...
- "tags": [
- "Core Switch",
- "Monitored"
- ],
- ...
-}
-```
+
+!!! note
+ Tags have changed substantially in NetBox v2.9. They are no longer created on-demand when editing an object, and their representation in the REST API now includes a complete depiction of the tag rather than only its label.
diff --git a/docs/models/ipam/aggregate.md b/docs/models/ipam/aggregate.md
index f43209619..ff5a50a39 100644
--- a/docs/models/ipam/aggregate.md
+++ b/docs/models/ipam/aggregate.md
@@ -1,6 +1,18 @@
# Aggregates
-The first step to documenting your IP space is to define its scope by creating aggregates. Aggregates establish the root of your IP address hierarchy by defining the top-level allocations that you're interested in managing. Most organizations will want to track some commonly-used private IP spaces, such as:
+IP addressing is by nature hierarchical. The first few levels of the IPv4 hierarchy, for example, look like this:
+
+* 0.0.0.0/0
+ * 0.0.0.0/1
+ * 0.0.0.0/2
+ * 64.0.0.0/2
+ * 128.0.0.0/1
+ * 128.0.0.0/2
+ * 192.0.0.0/2
+
+This hierarchy comprises 33 tiers of addressing, from /0 all the way down to individual /32 address (and much, much further to /128 for IPv6). Of course, most organizations are concerned with only relatively small portions of the total IP space, so tracking the uppermost of these tiers isn't necessary.
+
+NetBox allows us to specify the portions of IP space that are interesting to us by defining _aggregates_. Typically, an aggregate will correspond to either an allocation of public (globally routable) IP space granted by a regional authority, or a private (internally-routable) designation. Common private designations include:
* 10.0.0.0/8 (RFC 1918)
* 100.64.0.0/10 (RFC 6598)
@@ -8,8 +20,9 @@ The first step to documenting your IP space is to define its scope by creating a
* 192.168.0.0/16 (RFC 1918)
* One or more /48s within fd00::/8 (IPv6 unique local addressing)
-In addition to one or more of these, you'll want to create an aggregate for each globally-routable space your organization has been allocated. These aggregates should match the allocations recorded in public WHOIS databases.
+Each aggregate is assigned to a RIR. For "public" aggregates, this will be the real-world authority which has granted your organization permission to use the specified IP space on the public Internet. For "private" aggregates, this will be a statutory authority, such as RFC 1918. Each aggregate can also annotate that date on which it was allocated, where applicable.
-Each IP prefix will be automatically arranged under its parent aggregate if one exists. Note that it's advised to create aggregates only for IP ranges actually allocated to your organization (or marked for private use): There is no need to define aggregates for provider-assigned space which is only used on Internet circuits, for example.
+Prefixes are automatically arranged beneath their parent aggregates in NetBox. Typically you'll want to create aggregates only for the prefixes and IP addresses that your organization actually manages: There is no need to define aggregates for provider-assigned space which is only used on Internet circuits, for example.
-Aggregates cannot overlap with one another: They can only exist side-by-side. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix and automatically grouped under 10.0.0.0/8. Remember, the purpose of aggregates is to establish the root of your IP addressing hierarchy.
+!!! note
+ Because aggregates represent swaths of the global IP space, they cannot overlap with one another: They can only exist side-by-side. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a container prefix and automatically grouped under the 10.0.0.0/8 aggregate. Remember, the purpose of aggregates is to establish the root of your IP addressing hierarchy.
diff --git a/docs/models/ipam/ipaddress.md b/docs/models/ipam/ipaddress.md
index cbe12553d..04ac417db 100644
--- a/docs/models/ipam/ipaddress.md
+++ b/docs/models/ipam/ipaddress.md
@@ -2,16 +2,16 @@
An IP address comprises a single host address (either IPv4 or IPv6) and its subnet mask. Its mask should match exactly how the IP address is configured on an interface in the real world.
-Like prefixes, an IP address can optionally be assigned to a VRF (otherwise, it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs.
+Like a prefix, an IP address can optionally be assigned to a VRF (otherwise, it will appear in the "global" table). IP addresses are automatically arranged under parent prefixes within their respective VRFs according to the IP hierarchy.
-Also like prefixes, each IP address can be assigned a status and a role. Statuses are hard-coded in NetBox and include the following:
+Each IP address can also be assigned an operational status and a functional role. Statuses are hard-coded in NetBox and include the following:
* Active
* Reserved
* Deprecated
* DHCP
-Each IP address can optionally be assigned a special role. Roles are used to indicate some special attribute of an IP address: for example, it is used as a loopback, or is a virtual IP maintained using VRRP. (Note that this differs in purpose from a _functional_ role, and thus cannot be customized.) Available roles include:
+Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include:
* Loopback
* Secondary
@@ -21,7 +21,10 @@ Each IP address can optionally be assigned a special role. Roles are used to ind
* HSRP
* GLBP
-An IP address can be assigned to a device or virtual machine interface, and an interface may have multiple IP addresses assigned to it. Further, each device and virtual machine may have one of its interface IPs designated as its primary IP address (one for IPv4 and one for IPv6).
+An IP address can be assigned to any device or virtual machine interface, and an interface may have multiple IP addresses assigned to it. Further, each device and virtual machine may have one of its interface IPs designated as its primary IP per address family (one for IPv4 and one for IPv6).
+
+!!! note
+ When primary IPs are set for both IPv4 and IPv6, NetBox will prefer IPv6. This can be changed by setting the `PREFER_IPV4` configuration parameter.
## Network Address Translation (NAT)
diff --git a/docs/models/ipam/prefix.md b/docs/models/ipam/prefix.md
index 9ab5382a5..bd5e9695f 100644
--- a/docs/models/ipam/prefix.md
+++ b/docs/models/ipam/prefix.md
@@ -2,7 +2,7 @@
A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address: All bits in the address not covered by the mask must be zero. (In other words, a prefix cannot be a specific IP address.)
-Prefixes are automatically arranged by their parent aggregates. Additionally, each prefix can be assigned to a particular site and VRF (routing table). All prefixes not assigned to a VRF will appear in the "global" table.
+Prefixes are automatically organized by their parent aggregates. Additionally, each prefix can be assigned to a particular site and virtual routing and forwarding instance (VRF). Each VRF represents a separate IP space or routing table. All prefixes not assigned to a VRF are considered to be in the "global" table.
Each prefix can be assigned a status and a role. These terms are often used interchangeably so it's important to recognize the difference between them. The **status** defines a prefix's operational state. Statuses are hard-coded in NetBox and can be one of the following:
@@ -13,6 +13,6 @@ Each prefix can be assigned a status and a role. These terms are often used inte
On the other hand, a prefix's **role** defines its function. Role assignment is optional and roles are fully customizable. For example, you might create roles to differentiate between production and development infrastructure.
-A prefix may also be assigned to a VLAN. This association is helpful for identifying which prefixes are included when reviewing a list of VLANs.
+A prefix may also be assigned to a VLAN. This association is helpful for associating address space with layer two domains. A VLAN may have multiple prefixes assigned to it.
-The prefix model include a "pool" flag. If enabled, NetBox will treat this prefix as a range (such as a NAT pool) wherein every IP address is valid and assignable. This logic is used for identifying available IP addresses within a prefix. If this flag is disabled, NetBox will assume that the first and last (broadcast) address within the prefix are unusable.
+The prefix model include an "is pool" flag. If enabled, NetBox will treat this prefix as a range (such as a NAT pool) wherein every IP address is valid and assignable. This logic is used when identifying available IP addresses within a prefix. If this flag is disabled, NetBox will assume that the first and last (broadcast) address within an IPv4 prefix are unusable.
diff --git a/docs/models/ipam/rir.md b/docs/models/ipam/rir.md
index 69c34e72d..6904381ac 100644
--- a/docs/models/ipam/rir.md
+++ b/docs/models/ipam/rir.md
@@ -1,7 +1,7 @@
# Regional Internet Registries (RIRs)
-[Regional Internet registries](https://en.wikipedia.org/wiki/Regional_Internet_registry) are responsible for the allocation of globally-routable address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for internal use, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. There also exist lower-tier registries which serve a particular geographic area.
+[Regional Internet registries](https://en.wikipedia.org/wiki/Regional_Internet_registry) are responsible for the allocation of globally-routable address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for internal use, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. There also exist lower-tier registries which serve particular geographic areas.
-Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own). The RIR model includes a boolean flag which indicates whether the RIR allocates only private IP space.
+Users can create whatever RIRs they like, but each aggregate must be assigned to one RIR. The RIR model includes a boolean flag which indicates whether the RIR allocates only private IP space.
-For example, suppose your organization has been allocated 104.131.0.0/16 by ARIN. It also makes use of RFC 1918 addressing internally. You would first create RIRs named ARIN and RFC 1918, then create an aggregate for each of these top-level prefixes, assigning it to its respective RIR.
+For example, suppose your organization has been allocated 104.131.0.0/16 by ARIN. It also makes use of RFC 1918 addressing internally. You would first create RIRs named "ARIN" and "RFC 1918," then create an aggregate for each of these top-level prefixes, assigning it to its respective RIR.
diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md
index 48f24006c..f252204c5 100644
--- a/docs/models/ipam/vlan.md
+++ b/docs/models/ipam/vlan.md
@@ -1,6 +1,6 @@
# VLANs
-A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site and/or VLAN group.
+A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site, tenant, and/or VLAN group.
Each VLAN must be assigned one of the following operational statuses:
@@ -8,4 +8,4 @@ Each VLAN must be assigned one of the following operational statuses:
* Reserved
* Deprecated
-Each VLAN may also be assigned a functional role. Prefixes and VLANs share the same set of customizable roles.
+As with prefixes, each VLAN may also be assigned a functional role. Prefixes and VLANs share the same set of customizable roles.
diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md
index 1fa31c522..7a0bb80ff 100644
--- a/docs/models/ipam/vlangroup.md
+++ b/docs/models/ipam/vlangroup.md
@@ -1,3 +1,5 @@
# VLAN Groups
-VLAN groups can be used to organize VLANs within NetBox. Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group.
+VLAN groups can be used to organize VLANs within NetBox. Each group may optionally be assigned to a specific site, but a group cannot belong to multiple sites.
+
+Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group.
diff --git a/docs/models/ipam/vrf.md b/docs/models/ipam/vrf.md
index c3d3390e4..599d05c82 100644
--- a/docs/models/ipam/vrf.md
+++ b/docs/models/ipam/vrf.md
@@ -1,12 +1,12 @@
# Virtual Routing and Forwarding (VRF)
-A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space).
+A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space). Each VRF may be assigned to a specific tenant to aid in organizing the available IP space by customer or internal user.
Each VRF is assigned a unique name and an optional route distinguisher (RD). The RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced.
-Each prefix and IP address may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any IP prefix or address not assigned to a VRF is said to belong to the "global" table.
+Each prefix and IP address may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any prefix or IP address not assigned to a VRF is said to belong to the "global" table.
-By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This behavior can be disabled by setting the "enforce unique" flag on the VRF model.
+By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This behavior can be toggled by setting the "enforce unique" flag on the VRF model.
!!! note
Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting.
diff --git a/docs/models/secrets/secretrole.md b/docs/models/secrets/secretrole.md
index 8997ed52a..23f68912b 100644
--- a/docs/models/secrets/secretrole.md
+++ b/docs/models/secrets/secretrole.md
@@ -7,5 +7,3 @@ Each secret is assigned a functional role which indicates what it is used for. S
* RADIUS/TACACS+ keys
* IKE key strings
* Routing protocol shared secrets
-
-Roles are also used to control access to secrets. Each role is assigned an arbitrary number of groups and/or users. Only the users associated with a role have permission to decrypt the secrets assigned to that role. (A superuser has permission to decrypt all secrets, provided they have an active user key.)
diff --git a/docs/models/tenancy/tenant.md b/docs/models/tenancy/tenant.md
index f7cf68ab8..60a160b9e 100644
--- a/docs/models/tenancy/tenant.md
+++ b/docs/models/tenancy/tenant.md
@@ -1,6 +1,6 @@
# Tenants
-A tenant represents a discrete entity for administrative purposes. Typically, tenants are used to represent individual customers or internal departments within an organization. The following objects can be assigned to tenants:
+A tenant represents a discrete grouping of resources used for administrative purposes. Typically, tenants are used to represent individual customers or internal departments within an organization. The following objects can be assigned to tenants:
* Sites
* Racks
@@ -11,6 +11,7 @@ A tenant represents a discrete entity for administrative purposes. Typically, te
* IP addresses
* VLANs
* Circuits
+* Clusters
* Virtual machines
-Tenant assignment is used to signify ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate.
+Tenant assignment is used to signify the ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate.
diff --git a/docs/models/tenancy/tenantgroup.md b/docs/models/tenancy/tenantgroup.md
index a2ed7e324..078a71a72 100644
--- a/docs/models/tenancy/tenantgroup.md
+++ b/docs/models/tenancy/tenantgroup.md
@@ -1,5 +1,5 @@
# Tenant Groups
-Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional.
+Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Departments." The assignment of a tenant to a group is optional.
-Tenant groups may be nested to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team.
+Tenant groups may be nested recursively to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team.
diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md
new file mode 100644
index 000000000..48970dd05
--- /dev/null
+++ b/docs/models/users/objectpermission.md
@@ -0,0 +1,55 @@
+# Object Permissions
+
+A permission in NetBox represents a relationship shared by several components:
+
+* Object type(s) - One or more types of object in NetBox
+* User(s)/Group(s) - One or more users or groups of users
+* Action(s) - The action(s) that can be performed on an object
+* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects
+
+At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s).
+
+## Actions
+
+There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete):
+
+* **View** - Retrieve an object from the database
+* **Add** - Create a new object
+* **Change** - Modify an existing object
+* **Delete** - Delete an existing object
+
+In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field.
+
+!!! note
+ Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.
+
+## Constraints
+
+Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
+
+All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints.
+
+```json
+{
+ "status": "active",
+ "region__name": "Americas"
+}
+```
+
+The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region.
+
+To achieve a logical OR with a different set of constraints, define multiple objects within a list. For example, if you want to constrain the permission to VLANs with an ID between 100 and 199 _or_ a status of "reserved," do the following:
+
+```json
+[
+ {
+ "vid__gte": 100,
+ "vid__lt": 200
+ },
+ {
+ "status": "reserved"
+ }
+]
+```
+
+Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation.
diff --git a/docs/models/users/token.md b/docs/models/users/token.md
index bbeb2284b..d0e0f8609 100644
--- a/docs/models/users/token.md
+++ b/docs/models/users/token.md
@@ -1,12 +1,12 @@
## Tokens
-A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
+A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile.
!!! note
The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
-By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
+By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md
index 6d8ce4214..3311ad42d 100644
--- a/docs/models/virtualization/cluster.md
+++ b/docs/models/virtualization/cluster.md
@@ -1,5 +1,5 @@
# Clusters
-A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned to a group and/or site.
+A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant.
-Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
+Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
diff --git a/docs/models/virtualization/clustergroup.md b/docs/models/virtualization/clustergroup.md
index 9e1e17315..6dd0f9688 100644
--- a/docs/models/virtualization/clustergroup.md
+++ b/docs/models/virtualization/clustergroup.md
@@ -1,3 +1,3 @@
# Cluster Groups
-Cluster groups may be created for the purpose of organizing clusters. The assignment of clusters to groups is optional.
+Cluster groups may be created for the purpose of organizing clusters. The arrangement of clusters into groups is optional.
diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md
index 5a82f8267..40e9ef2c0 100644
--- a/docs/models/virtualization/virtualmachine.md
+++ b/docs/models/virtualization/virtualmachine.md
@@ -1,11 +1,14 @@
# Virtual Machines
-A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster.
+A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster.
-Like devices, each VM can be assigned a platform and have interfaces created on it. VM interfaces behave similarly to device interfaces, and can be assigned IP addresses, VLANs, and services. However, given their virtual nature, they cannot be connected to other interfaces. Unlike physical devices, VMs cannot be assigned console or power ports, device bays, or inventory items.
+Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:
-The following resources can be defined for each VM:
+* Active
+* Offline
+* Planned
+* Staged
+* Failed
+* Decommissioning
-* vCPU count
-* Memory (MB)
-* Disk space (GB)
+Additional fields are available for annotating the vCPU count, memory (GB), and disk (GB) allocated to each VM. Each VM may optionally be assigned to a tenant. Virtual machines may have virtual interfaces assigned to them, but do not support any physical component.
diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md
new file mode 100644
index 000000000..6fac7ce36
--- /dev/null
+++ b/docs/models/virtualization/vminterface.md
@@ -0,0 +1,3 @@
+## Interfaces
+
+Virtual machine interfaces behave similarly to device interfaces, and can be assigned IP addresses, VLANs, and services. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them.
diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md
index 364b2cd9d..f314c5371 120000
--- a/docs/release-notes/index.md
+++ b/docs/release-notes/index.md
@@ -1 +1 @@
-version-2.8.md
\ No newline at end of file
+version-2.9.md
\ No newline at end of file
diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md
new file mode 100644
index 000000000..bf7354769
--- /dev/null
+++ b/docs/release-notes/version-2.9.md
@@ -0,0 +1,120 @@
+# NetBox v2.9
+
+## v2.9.0 (FUTURE)
+
+**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.9 release will be provided from this beta, and users should assume that all data entered into the application will be lost. Please reference [the v2.9 beta documentation](https://netbox.readthedocs.io/en/develop-2.9/) for further information regarding this release.
+
+### New Features
+
+#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
+
+NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group permission to perform a certain action on one or more types of objects, an administrator can optionally specify a set of constraints. The permission will apply only to objects which match the specified constraints. For example, assigning permission to modify devices with the constraint `{"tenant__group__name": "Customers"}` would allow the associated users/groups to perform an action only on devices assigned to a tenant belonging to the "Customers" group.
+
+#### Background Execution of Scripts & Reports ([#2006](https://github.com/netbox-community/netbox/issues/2006))
+
+When running a report or custom script, its execution is now queued for background processing and the user receives an immediate response indicating its status. This prevents long-running scripts from resulting in a timeout error. Once the execution has completed, the page will automatically refresh to display its results. Both scripts and reports now store their output in the new JobResult model. (The ReportResult model has been removed.)
+
+#### Named Virtual Chassis ([#2018](https://github.com/netbox-community/netbox/issues/2018))
+
+The VirtualChassis model now has a mandatory `name` field. Names are assigned to the virtual chassis itself rather than referencing the master VC member. Additionally, the designation of a master is now optional: a virtual chassis may have only non-master members.
+
+#### Changes to Tag Creation ([#3703](https://github.com/netbox-community/netbox/issues/3703))
+
+Tags are no longer created automatically: A tag must be created by a user before it can be applied to any object. Additionally, the REST API representation of assigned tags has been expanded to be consistent with other objects.
+
+#### Dedicated Model for VM Interfaces ([#4721](https://github.com/netbox-community/netbox/issues/4721))
+
+A new model has been introduced to represent virtual machine interfaces. Although this change is largely transparent to the end user, note that the IP address model no longer has a foreign key to the Interface model under the DCIM app. This has been replaced with a generic foreign key named `assigned_object`.
+
+#### REST API Endpoints for Users and Groups ([#4877](https://github.com/netbox-community/netbox/issues/4877))
+
+Two new REST API endpoints have been added to facilitate the retrieval and manipulation of users and groups:
+
+* `/api/users/groups/`
+* `/api/users/users/`
+
+### Enhancements
+
+* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components and component templates
+* [#4639](https://github.com/netbox-community/netbox/issues/4639) - Improve performance of web UI prefixes list
+* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
+* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components
+* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports
+* [#4793](https://github.com/netbox-community/netbox/issues/4793) - Add `description` field to device component templates
+* [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports
+* [#4806](https://github.com/netbox-community/netbox/issues/4806) - Add a `url` field to all API serializers
+* [#4807](https://github.com/netbox-community/netbox/issues/4807) - Add bulk edit ability for device bay templates
+* [#4817](https://github.com/netbox-community/netbox/issues/4817) - Standardize device/VM component `name` field to 64 characters
+* [#4837](https://github.com/netbox-community/netbox/issues/4837) - Use dynamic form widget for relationships to MPTT objects (e.g. regions)
+* [#4840](https://github.com/netbox-community/netbox/issues/4840) - Enable change logging for config contexts
+* [#4940](https://github.com/netbox-community/netbox/issues/4940) - Add an `occupied` field to rack unit representations for rack elevation views
+* [#4945](https://github.com/netbox-community/netbox/issues/4945) - Add a user-friendly 403 error page
+* [#4969](https://github.com/netbox-community/netbox/issues/4969) - Replace secret role user/group assignment with object permissions
+* [#4982](https://github.com/netbox-community/netbox/issues/4982) - Extended ObjectVar to allow filtering API query
+* [#4994](https://github.com/netbox-community/netbox/issues/4994) - Add `cable` attribute to PowerFeed API serializer
+* [#4997](https://github.com/netbox-community/netbox/issues/4997) - The browsable API now lists available endpoints alphabetically
+* [#5024](https://github.com/netbox-community/netbox/issues/5024) - List available options for choice fields within CSV import forms
+
+### Configuration Changes
+
+* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
+* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
+
+### REST API Changes
+
+* Added new endpoints for users, groups, and permissions under `/api/users/`.
+* A `url` field is now included on all object representations, identifying the unique REST API URL for each object.
+* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name.
+* The assignment of tags to an object is now achieved in the same manner as specifying any other related device. The `tags` field accepts a list of JSON objects each matching a desired tag. (Alternatively, a list of numeric primary keys corresponding to tags may be passed instead.) For example:
+
+```json
+"tags": [
+ {"name": "First Tag"},
+ {"name": "Second Tag"}
+]
+```
+
+* Legacy numeric values for choice fields are no longer conveyed or accepted.
+* dcim.Cable: Added `tags` field
+* dcim.ConsolePort: Added `label` field
+* dcim.ConsolePortTemplate: Added `description` and `label` fields
+* dcim.ConsoleServerPort: Added `label` field
+* dcim.ConsoleServerPortTemplate: Added `description` and `label` fields
+* dcim.DeviceBay: Added `label` field
+* dcim.DeviceBayTemplate: Added `description` and `label` fields
+* dcim.FrontPort: Added `label` field
+* dcim.FrontPortTemplate: Added `description` and `label` fields
+* dcim.Interface: Added `label` field
+* dcim.InterfaceTemplate: Added `description` and `label` fields
+* dcim.PowerFeed: Added `cable` field
+* dcim.PowerPanel: Added `tags` field
+* dcim.PowerPort: Added ``label` field
+* dcim.PowerPortTemplate: Added `description` and `label` fields
+* dcim.PowerOutlet: Added `label` field
+* dcim.PowerOutletTemplate: Added `description` and `label` fields
+* dcim.Rack: Added an `occupied` field to rack unit representations for rack elevation views
+* dcim.RackGroup: Added a `_depth` attribute indicating an object's position in the tree.
+* dcim.RackReservation: Added `tags` field
+* dcim.RearPort: Added `label` field
+* dcim.RearPortTemplate: Added `description` and `label` fields
+* dcim.Region: Added a `_depth` attribute indicating an object's position in the tree.
+* dcim.VirtualChassis: Added `name` field (required)
+* extras.ConfigContext: Added `created` and `last_updated` fields
+* extras.JobResult: Added the `/api/extras/job-results/` endpoint
+* extras.Report: The `failed` field has been removed. The `completed` (boolean) and `status` (string) fields have been introduced to convey the status of a report's most recent execution. Additionally, the `result` field now conveys the nested representation of a JobResult.
+* extras.Script: Added `module` and `result` fields. The `result` field now conveys the nested representation of a JobResult.
+* extras.Tag: The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed.
+* ipam.IPAddress: Removed `interface` field; replaced with `assigned_object` generic foreign key. This may represent either a device interface or a virtual machine interface. Assign an object by setting `assigned_object_type` and `assigned_object_id`.
+* tenancy.TenantGroup: Added a `_depth` attribute indicating an object's position in the tree.
+* users.ObjectPermissions: Added the `/api/users/permissions/` endpoint
+* virtualization.VMInterface: Removed `type` field (VM interfaces have no type)
+
+### Other Changes
+
+* A new model, `VMInterface` has been introduced to represent interfaces assigned to VirtualMachine instances. Previously, these interfaces utilized the DCIM model `Interface`. Instances will be replicated automatically upon upgrade, however any custom code which references or manipulates virtual machine interfaces will need to be updated accordingly.
+* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.
+* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens.
+* Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead).
+* Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`).
+* Virtual chassis are now created by navigating to `/dcim/virtual-chassis/add/` rather than via the devices list.
+* A name is required when creating a virtual chassis.
diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md
new file mode 100644
index 000000000..5d5777483
--- /dev/null
+++ b/docs/rest-api/authentication.md
@@ -0,0 +1,30 @@
+# REST API Authentication
+
+The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API.
+
+{!docs/models/users/token.md!}
+
+## Authenticating to the API
+
+An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token:
+
+```
+$ curl -H "Authorization: Token $TOKEN" \
+-H "Accept: application/json; indent=4" \
+http://netbox/api/dcim/sites/
+{
+ "count": 10,
+ "next": null,
+ "previous": null,
+ "results": [...]
+}
+```
+
+A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../../configuration/optional-settings/#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response:
+
+```
+$ curl http://netbox/api/dcim/sites/
+{
+ "detail": "Authentication credentials were not provided."
+}
+```
diff --git a/docs/rest-api/filtering.md b/docs/rest-api/filtering.md
new file mode 100644
index 000000000..1ec6752a4
--- /dev/null
+++ b/docs/rest-api/filtering.md
@@ -0,0 +1,87 @@
+# REST API Filtering
+
+## Filtering Objects
+
+The objects returned by an API list endpoint can be filtered by attaching one or more query parameters to the request URL. For example, `GET /api/dcim/sites/?status=active` will return only sites with a status of "active."
+
+Multiple parameters can be joined to further narrow results. For example, `GET /api/dcim/sites/?status=active®ion=europe` will return only active sites within the Europe region.
+
+Generally, passing multiple values for a single parameter will result in a logical OR operation. For example, `GET /api/dcim/sites/?region=north-america®ion=south-america` will return sites in North America _or_ South America. However, a logical AND operation will be used in instances where a field may have multiple values, such as tags. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites which have both the "foo" _and_ "bar" tags applied.
+
+### Filtering by Choice Field
+
+Some models have fields which are limited to specific choices, such as the `status` field on the Prefix model. To find all available choices for this field, make an authenticated `OPTIONS` request to the model's list endpoint, and use `jq` to extract the relevant parameters:
+
+```no-highlight
+$ curl -s -X OPTIONS \
+-H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+http://netbox/api/ipam/prefixes/ | jq ".actions.POST.status.choices"
+[
+ {
+ "value": "container",
+ "display_name": "Container"
+ },
+ {
+ "value": "active",
+ "display_name": "Active"
+ },
+ {
+ "value": "reserved",
+ "display_name": "Reserved"
+ },
+ {
+ "value": "deprecated",
+ "display_name": "Deprecated"
+ }
+]
+```
+
+!!! note
+ The above works only if the API token used to authenticate the request has permission to make a `POST` request to this endpoint.
+
+### Filtering by Custom Field
+
+To filter results by a custom field value, prepend `cf_` to the custom field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:
+
+```no-highlight
+GET /api/dcim/sites/?cf_foo=123
+```
+
+Custom fields can be mixed with built-in fields to further narrow results. When creating a custom string field, the type of filtering selected (loose versus exact) determines whether partial or full matching is used.
+
+## Lookup Expressions
+
+Certain model fields also support filtering using additional lookup expressions. This allows
+for negation and other context-specific filtering.
+
+These lookup expressions can be applied by adding a suffix to the desired field's name, e.g. `mac_address__n`. In this case, the filter expression is for negation and it is separated by two underscores. Below are the lookup expressions that are supported across different field types.
+
+### Numeric Fields
+
+Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
+
+- `n` - not equal to (negation)
+- `lt` - less than
+- `lte` - less than or equal
+- `gt` - greater than
+- `gte` - greater than or equal
+
+### String Fields
+
+String based (char) fields (Name, Address, etc) support these lookup expressions:
+
+- `n` - not equal to (negation)
+- `ic` - case insensitive contains
+- `nic` - negated case insensitive contains
+- `isw` - case insensitive starts with
+- `nisw` - negated case insensitive starts with
+- `iew` - case insensitive ends with
+- `niew` - negated case insensitive ends with
+- `ie` - case sensitive exact match
+- `nie` - negated case sensitive exact match
+
+### Foreign Keys & Other Fields
+
+Certain other fields, namely foreign key relationships support just the negation
+expression: `n`.
diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md
new file mode 100644
index 000000000..d16cd059d
--- /dev/null
+++ b/docs/rest-api/overview.md
@@ -0,0 +1,531 @@
+# REST API Overview
+
+## What is a REST API?
+
+REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](http://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
+
+* `GET`: Retrieve an object or list of objects
+* `POST`: Create an object
+* `PUT` / `PATCH`: Modify an existing object. `PUT` requires all mandatory fields to be specified, while `PATCH` only expects the field that is being modified to be specified.
+* `DELETE`: Delete an existing object
+
+Additionally, the `OPTIONS` verb can be used to inspect a particular REST API endpoint and return all supported actions and their available parameters.
+
+One of the primary benefits of a REST API is its human-friendliness. Because it utilizes HTTP and JSON, it's very easy to interact with NetBox data on the command line using common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. The following command makes an HTTP `GET` request for information about a particular IP address, identified by its primary key, and uses `jq` to present the raw JSON data returned in a more human-friendly format. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)
+
+```no-highlight
+curl -s http://netbox/api/ipam/ip-addresses/2954/ | jq '.'
+```
+
+```json
+{
+ "id": 2954,
+ "url": "http://netbox/api/ipam/ip-addresses/2954/",
+ "family": {
+ "value": 4,
+ "label": "IPv4"
+ },
+ "address": "192.168.0.42/26",
+ "vrf": null,
+ "tenant": null,
+ "status": {
+ "value": "active",
+ "label": "Active"
+ },
+ "role": null,
+ "assigned_object_type": "dcim.interface",
+ "assigned_object_id": 114771,
+ "assigned_object": {
+ "id": 114771,
+ "url": "http://netbox/api/dcim/interfaces/114771/",
+ "device": {
+ "id": 2230,
+ "url": "http://netbox/api/dcim/devices/2230/",
+ "name": "router1",
+ "display_name": "router1"
+ },
+ "name": "et-0/1/2",
+ "cable": null,
+ "connection_status": null
+ },
+ "nat_inside": null,
+ "nat_outside": null,
+ "dns_name": "",
+ "description": "Example IP address",
+ "tags": [],
+ "custom_fields": {},
+ "created": "2020-08-04",
+ "last_updated": "2020-08-04T14:12:39.666885Z"
+}
+```
+
+Each attribute of the IP address is expressed as an attribute of the JSON object. Fields may include their own nested objects, as in the case of the `assigned_object` field above. Every object includes a primary key named `id` which uniquely identifies it in the database.
+
+## Interactive Documentation
+
+Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
+
+## Endpoint Hierarchy
+
+NetBox's entire REST API is housed under the API root at `https:///api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, plugins, secrets, tenancy, users, and virtualization. Within each application exists a separate path for each model. For example, the provider and circuit objects are located under the "circuits" application:
+
+* `/api/circuits/providers/`
+* `/api/circuits/circuits/`
+
+Likewise, the site, rack, and device objects are located under the "DCIM" application:
+
+* `/api/dcim/sites/`
+* `/api/dcim/racks/`
+* `/api/dcim/devices/`
+
+The full hierarchy of available endpoints can be viewed by navigating to the API root in a web browser.
+
+Each model generally has two views associated with it: a list view and a detail view. The list view is used to retrieve a list of multiple objects and to create new objects. The detail view is used to retrieve, update, or delete an single existing object. All objects are referenced by their numeric primary key (`id`).
+
+* `/api/dcim/devices/` - List existing devices or create a new device
+* `/api/dcim/devices/123/` - Retrieve, update, or delete the device with ID 123
+
+Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123:
+
+```
+GET /api/dcim/interfaces/?device_id=123
+```
+
+See the [filtering documentation](filtering.md) for more details.
+
+## Serialization
+
+The REST API employs two types of serializers to represent model data: base serializers and nested serializers. The base serializer is used to present the complete view of a model. This includes all database table fields which comprise the model, and may include additional metadata. A base serializer includes relationships to parent objects, but **does not** include child objects. For example, the `VLANSerializer` includes a nested representation its parent VLANGroup (if any), but does not include any assigned Prefixes.
+
+```json
+{
+ "id": 1048,
+ "site": {
+ "id": 7,
+ "url": "http://netbox/api/dcim/sites/7/",
+ "name": "Corporate HQ",
+ "slug": "corporate-hq"
+ },
+ "group": {
+ "id": 4,
+ "url": "http://netbox/api/ipam/vlan-groups/4/",
+ "name": "Production",
+ "slug": "production"
+ },
+ "vid": 101,
+ "name": "Users-Floor1",
+ "tenant": null,
+ "status": {
+ "value": 1,
+ "label": "Active"
+ },
+ "role": {
+ "id": 9,
+ "url": "http://netbox/api/ipam/roles/9/",
+ "name": "User Access",
+ "slug": "user-access"
+ },
+ "description": "",
+ "display_name": "101 (Users-Floor1)",
+ "custom_fields": {}
+}
+```
+
+### Related Objects
+
+Related objects (e.g. `ForeignKey` fields) are represented using nested serializers. A nested serializer provides a minimal representation of an object, including only its direct 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):
+
+```json
+{
+ "name": "MyNewDevice",
+ "rack": 123,
+ ...
+}
+```
+
+Or by a set of nested attributes which uniquely identify the rack:
+
+```json
+{
+ "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.
+
+### Generic Relations
+
+Some objects within NetBox have attributes which can reference an object of multiple types, known as _generic relations_. For example, an IP address can be assigned to either a device interface _or_ a virtual machine interface. When making this assignment via the REST API, we must specify two attributes:
+
+* `assigned_object_type` - The content type of the assigned object, defined as `.`
+* `assigned_object_id` - The assigned object's unique numeric ID
+
+Together, these values identify a unique object in NetBox. The assigned object (if any) is represented by the `assigned_object` attribute on the IP address model.
+
+```no-highlight
+curl -X POST \
+-H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+-H "Accept: application/json; indent=4" \
+http://netbox/api/ipam/ip-addresses/ \
+--data '{
+ "address": "192.0.2.1/24",
+ "assigned_object_type": "dcim.interface",
+ "assigned_object_id": 69023
+}'
+```
+
+```json
+{
+ "id": 56296,
+ "url": "http://netbox/api/ipam/ip-addresses/56296/",
+ "assigned_object_type": "dcim.interface",
+ "assigned_object_id": 69000,
+ "assigned_object": {
+ "id": 69000,
+ "url": "http://netbox/api/dcim/interfaces/69023/",
+ "device": {
+ "id": 2174,
+ "url": "http://netbox/api/dcim/devices/2174/",
+ "name": "device105",
+ "display_name": "device105"
+ },
+ "name": "ge-0/0/0",
+ "cable": null,
+ "connection_status": null
+ },
+ ...
+}
+```
+
+If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
+
+### 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 available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of an IP address looks like this:
+
+```
+GET /api/ipam/prefixes/13980/
+
+{
+ "id": 13980,
+ "url": "http://netbox/api/ipam/prefixes/13980/",
+ "family": {
+ "value": 4,
+ "label": "IPv4"
+ },
+ "prefix": "192.0.2.0/24",
+ "site": {
+ "id": 3,
+ "url": "http://netbox/api/dcim/sites/17/",
+ "name": "Site 23A",
+ "slug": "site-23a"
+ },
+ "vrf": null,
+ "tenant": null,
+ "vlan": null,
+ "status": {
+ "value": "container",
+ "label": "Container"
+ },
+ "role": {
+ "id": 17,
+ "url": "http://netbox/api/ipam/roles/17/",
+ "name": "Staging",
+ "slug": "staging"
+ },
+ "is_pool": false,
+ "description": "Example prefix",
+ "tags": [],
+ "custom_fields": {},
+ "created": "2018-12-10",
+ "last_updated": "2019-03-01T20:02:46.173540Z"
+}
+```
+
+The brief format is much more terse:
+
+```
+GET /api/ipam/prefixes/13980/?brief=1
+
+{
+ "id": 13980,
+ "url": "http://netbox/api/ipam/prefixes/13980/",
+ "family": 4,
+ "prefix": "10.40.3.0/24"
+}
+```
+
+The brief format is supported for both lists and individual objects.
+
+### Excluding Config Contexts
+
+When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext/) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views.
+
+## Pagination
+
+API responses which contain a list of many objects will be paginated for efficiency. The root JSON object returned by a list endpoint contains the following attributes:
+
+* `count`: The total number of all objects matching the query
+* `next`: A hyperlink to the next page of results (if applicable)
+* `previous`: A hyperlink to the previous page of results (if applicable)
+* `results`: The list of objects on the current page
+
+Here is an example of a paginated response:
+
+```
+HTTP 200 OK
+Allow: GET, POST, OPTIONS
+Content-Type: application/json
+Vary: Accept
+
+{
+ "count": 2861,
+ "next": "http://netbox/api/dcim/devices/?limit=50&offset=50",
+ "previous": null,
+ "results": [
+ {
+ "id": 231,
+ "name": "Device1",
+ ...
+ },
+ {
+ "id": 232,
+ "name": "Device2",
+ ...
+ },
+ ...
+ ]
+}
+```
+
+The default page is determined by the [`PAGINATE_COUNT`](../../configuration/optional-settings/#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
+
+```
+http://netbox/api/dcim/devices/?limit=100
+```
+
+The response will return devices 1 through 100. The URL provided in the `next` attribute of the response will return devices 101 through 200:
+
+```json
+{
+ "count": 2861,
+ "next": "http://netbox/api/dcim/devices/?limit=100&offset=100",
+ "previous": null,
+ "results": [...]
+}
+```
+
+The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../../configuration/optional-settings/#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request.
+
+!!! warning
+ Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database.
+
+## Interacting with Objects
+
+### Retrieving Multiple Objects
+
+To query NetBox for a list of objects, make a `GET` request to the model's _list_ endpoint. Objects are listed under the response object's `results` parameter.
+
+```no-highlight
+curl -s -X GET http://netbox/api/ipam/ip-addresses/ | jq '.'
+```
+
+```json
+{
+ "count": 42031,
+ "next": "http://netbox/api/ipam/ip-addresses/?limit=50&offset=50",
+ "previous": null,
+ "results": [
+ {
+ "id": 5618,
+ "address": "192.0.2.1/24",
+ ...
+ },
+ {
+ "id": 5619,
+ "address": "192.0.2.2/24",
+ ...
+ },
+ {
+ "id": 5620,
+ "address": "192.0.2.3/24",
+ ...
+ },
+ ...
+ ]
+}
+```
+
+### Retrieving a Single Object
+
+To query NetBox for a single object, make a `GET` request to the model's _detail_ endpoint specifying its unique numeric ID.
+
+!!! note
+ Note that the trailing slash is required. Omitting this will return a 302 redirect.
+
+```no-highlight
+curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.'
+```
+
+```json
+{
+ "id": 5618,
+ "address": "192.0.2.1/24",
+ ...
+}
+```
+
+### Creating a New Object
+
+To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](../authentication/) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`.
+
+```no-highlight
+curl -s -X POST \
+-H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+http://netbox/api/ipam/prefixes/ \
+--data '{"prefix": "192.0.2.0/24", "site": 6}' | jq '.'
+```
+
+```json
+{
+ "id": 18691,
+ "url": "http://netbox/api/ipam/prefixes/18691/",
+ "family": {
+ "value": 4,
+ "label": "IPv4"
+ },
+ "prefix": "192.0.2.0/24",
+ "site": {
+ "id": 6,
+ "url": "http://netbox/api/dcim/sites/6/",
+ "name": "US-East 4",
+ "slug": "us-east-4"
+ },
+ "vrf": null,
+ "tenant": null,
+ "vlan": null,
+ "status": {
+ "value": "active",
+ "label": "Active"
+ },
+ "role": null,
+ "is_pool": false,
+ "description": "",
+ "tags": [],
+ "custom_fields": {},
+ "created": "2020-08-04",
+ "last_updated": "2020-08-04T20:08:39.007125Z"
+}
+```
+
+### Creating Multiple Objects
+
+To create multiple instances of a model using a single request, make a `POST` request to the model's _list_ endpoint with a list of JSON objects representing each instance to be created. If successful, the response will contain a list of the newly created instances. The example below illustrates the creation of three new sites.
+
+```no-highlight
+curl -X POST -H "Authorization: Token $TOKEN" \
+-H "Content-Type: application/json" \
+-H "Accept: application/json; indent=4" \
+http://netbox/api/dcim/sites/ \
+--data '[
+{"name": "Site 1", "slug": "site-1", "region": {"name": "United States"}},
+{"name": "Site 2", "slug": "site-2", "region": {"name": "United States"}},
+{"name": "Site 3", "slug": "site-3", "region": {"name": "United States"}}
+]'
+```
+
+```json
+[
+ {
+ "id": 21,
+ "url": "http://netbox/api/dcim/sites/21/",
+ "name": "Site 1",
+ ...
+ },
+ {
+ "id": 22,
+ "url": "http://netbox/api/dcim/sites/22/",
+ "name": "Site 2",
+ ...
+ },
+ {
+ "id": 23,
+ "url": "http://netbox/api/dcim/sites/23/",
+ "name": "Site 3",
+ ...
+ }
+]
+```
+
+### Modifying an Object
+
+To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified.
+
+```no-highlight
+curl -s -X PATCH \
+> -H "Authorization: Token $TOKEN" \
+> -H "Content-Type: application/json" \
+> http://netbox/api/ipam/prefixes/18691/ \
+> --data '{"status": "reserved"}' | jq '.'
+```
+
+```json
+{
+ "id": 18691,
+ "url": "http://netbox/api/ipam/prefixes/18691/",
+ "family": {
+ "value": 4,
+ "label": "IPv4"
+ },
+ "prefix": "192.0.2.0/24",
+ "site": {
+ "id": 6,
+ "url": "http://netbox/api/dcim/sites/6/",
+ "name": "US-East 4",
+ "slug": "us-east-4"
+ },
+ "vrf": null,
+ "tenant": null,
+ "vlan": null,
+ "status": {
+ "value": "reserved",
+ "label": "Reserved"
+ },
+ "role": null,
+ "is_pool": false,
+ "description": "",
+ "tags": [],
+ "custom_fields": {},
+ "created": "2020-08-04",
+ "last_updated": "2020-08-04T20:14:55.709430Z"
+}
+```
+
+!!! note "PUT versus PATCH"
+ The NetBox REST API support the use of either `PUT` or `PATCH` to modify an existing object. The difference is that a `PUT` request requires the user to specify a _complete_ representation of the object being modified, whereas a `PATCH` request need include only the attributes that are being updated. For most purposes, using `PATCH` is recommended.
+
+### Deleting an Object
+
+To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body.
+
+```no-highlight
+curl -s -X DELETE \
+-H "Authorization: Token $TOKEN" \
+http://netbox/api/ipam/prefixes/18691/
+```
+
+Note that `DELETE` requests do not return any data: If successful, the API will return a 204 (No Content) response.
+
+!!! note
+ You can run `curl` with the verbose (`-v`) flag to inspect the HTTP response codes.
diff --git a/docs/api/working-with-secrets.md b/docs/rest-api/working-with-secrets.md
similarity index 54%
rename from docs/api/working-with-secrets.md
rename to docs/rest-api/working-with-secrets.md
index 129bd0855..dafbb7239 100644
--- a/docs/api/working-with-secrets.md
+++ b/docs/rest-api/working-with-secrets.md
@@ -1,16 +1,19 @@
# Working with Secrets
-As with most other objects, the NetBox API can be used to create, modify, and delete secrets. However, additional steps are needed to encrypt or decrypt secret data.
+As with most other objects, the REST API can be used to view, create, modify, and delete secrets. However, additional steps are needed to encrypt or decrypt secret data.
## Generating a Session Key
In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../../core-functionality/secrets/#user-keys). The private key must be POSTed with the name `private_key`.
-```
-$ curl -X POST http://localhost:8000/api/secrets/get-session-key/ \
--H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
+```no-highlight
+$ curl -X POST http://netbox/api/secrets/get-session-key/ \
+-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
--data-urlencode "private_key@"
+```
+
+```json
{
"session_key": "dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
}
@@ -19,94 +22,106 @@ $ curl -X POST http://localhost:8000/api/secrets/get-session-key/ \
!!! note
To read the private key from a file, use the convention above. Alternatively, the private key can be read from an environment variable using `--data-urlencode "private_key=$PRIVATE_KEY"`.
-The request uses your private key to unlock your stored copy of the master key and generate a session key which can be attached in the `X-Session-Key` header of future API requests.
+The request uses the provided private key to unlock your stored copy of the master key and generate a temporary session key, which can be attached in the `X-Session-Key` header of future API requests.
## Retrieving Secrets
A session key is not needed to retrieve unencrypted secrets: The secret is returned like any normal object with its `plaintext` field set to null.
-```
-$ curl http://localhost:8000/api/secrets/secrets/2587/ \
--H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
+```no-highlight
+$ curl http://netbox/api/secrets/secrets/2587/ \
+-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4"
+```
+
+```json
{
"id": 2587,
+ "url": "http://netbox/api/secrets/secrets/2587/",
"device": {
"id": 1827,
- "url": "http://localhost:8000/api/dcim/devices/1827/",
+ "url": "http://netbox/api/dcim/devices/1827/",
"name": "MyTestDevice",
"display_name": "MyTestDevice"
},
"role": {
"id": 1,
- "url": "http://localhost:8000/api/secrets/secret-roles/1/",
+ "url": "http://netbox/api/secrets/secret-roles/1/",
"name": "Login Credentials",
"slug": "login-creds"
},
"name": "admin",
"plaintext": null,
"hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=",
+ "tags": [],
+ "custom_fields": {},
"created": "2017-03-21",
"last_updated": "2017-03-21T19:28:44.265582Z"
}
```
-To decrypt a secret, we must include our session key in the `X-Session-Key` header:
+To decrypt a secret, we must include our session key in the `X-Session-Key` header when sending the `GET` request:
-```
-$ curl http://localhost:8000/api/secrets/secrets/2587/ \
--H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
+```no-highlight
+$ curl http://netbox/api/secrets/secrets/2587/ \
+-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
+```
+
+```json
{
"id": 2587,
+ "url": "http://netbox/api/secrets/secrets/2587/",
"device": {
"id": 1827,
- "url": "http://localhost:8000/api/dcim/devices/1827/",
+ "url": "http://netbox/api/dcim/devices/1827/",
"name": "MyTestDevice",
"display_name": "MyTestDevice"
},
"role": {
"id": 1,
- "url": "http://localhost:8000/api/secrets/secret-roles/1/",
+ "url": "http://netbox/api/secrets/secret-roles/1/",
"name": "Login Credentials",
"slug": "login-creds"
},
"name": "admin",
"plaintext": "foobar",
"hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=",
+ "tags": [],
+ "custom_fields": {},
"created": "2017-03-21",
"last_updated": "2017-03-21T19:28:44.265582Z"
}
```
-Lists of secrets can be decrypted in this manner as well:
+Multiple secrets within a list can be decrypted in this manner as well:
-```
-$ curl http://localhost:8000/api/secrets/secrets/?limit=3 \
--H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
+```no-highlight
+$ curl http://netbox/api/secrets/secrets/?limit=3 \
+-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
+```
+
+```json
{
"count": 3482,
- "next": "http://localhost:8000/api/secrets/secrets/?limit=3&offset=3",
+ "next": "http://netbox/api/secrets/secrets/?limit=3&offset=3",
"previous": null,
"results": [
{
"id": 2587,
- ...
"plaintext": "foobar",
...
},
{
"id": 2588,
- ...
"plaintext": "MyP@ssw0rd!",
...
},
{
"id": 2589,
- ...
"plaintext": "AnotherSecret!",
...
},
@@ -114,25 +129,44 @@ $ curl http://localhost:8000/api/secrets/secrets/?limit=3 \
}
```
-## Creating Secrets
+## Creating and Updating Secrets
-Session keys are also used to decrypt new or modified secrets. This is done by setting the `plaintext` field of the submitted object:
+Session keys are required when creating or modifying secrets. The secret's `plaintext` attribute is set to its non-encrypted value, and NetBox uses the session key to compute and store the encrypted value.
-```
-$ curl -X POST http://localhost:8000/api/secrets/secrets/ \
+```no-highlight
+$ curl -X POST http://netbox/api/secrets/secrets/ \
-H "Content-Type: application/json" \
--H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
+-H "Authorization: Token $TOKEN" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" \
--data '{"device": 1827, "role": 1, "name": "backup", "plaintext": "Drowssap1"}'
+```
+
+```json
{
- "id": 2590,
- "device": 1827,
- "role": 1,
+ "id": 6194,
+ "url": "http://netbox/api/secrets/secrets/9194/",
+ "device": {
+ "id": 1827,
+ "url": "http://netbox/api/dcim/devices/1827/",
+ "name": "device43",
+ "display_name": "device43"
+ },
+ "role": {
+ "id": 1,
+ "url": "http://netbox/api/secrets/secret-roles/1/",
+ "name": "Login Credentials",
+ "slug": "login-creds"
+ },
"name": "backup",
- "plaintext": "Drowssap1"
+ "plaintext": "Drowssap1",
+ "hash": "pbkdf2_sha256$1000$J9db8sI5vBrd$IK6nFXnFl+K+nR5/KY8RSDxU1skYL8G69T5N3jZxM7c=",
+ "tags": [],
+ "custom_fields": {},
+ "created": "2020-08-05",
+ "last_updated": "2020-08-05T16:51:14.990506Z"
}
```
!!! note
- Don't forget to include the `Content-Type: application/json` header when making a POST request.
+ Don't forget to include the `Content-Type: application/json` header when making a POST or PATCH request.
diff --git a/mkdocs.yml b/mkdocs.yml
index b8633ea8f..a94aa3cc4 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -20,8 +20,9 @@ nav:
- 1. PostgreSQL: 'installation/1-postgresql.md'
- 2. Redis: 'installation/2-redis.md'
- 3. NetBox: 'installation/3-netbox.md'
- - 4. HTTP Daemon: 'installation/4-http-daemon.md'
- - 5. LDAP (Optional): 'installation/5-ldap.md'
+ - 4. Gunicorn: 'installation/4-gunicorn.md'
+ - 5. HTTP Server: 'installation/5-http-server.md'
+ - 6. LDAP (Optional): 'installation/6-ldap.md'
- Upgrading NetBox: 'installation/upgrading.md'
- Migrating to systemd: 'installation/migrating-to-systemd.md'
- Configuration:
@@ -39,11 +40,11 @@ nav:
- Circuits: 'core-functionality/circuits.md'
- Power Tracking: 'core-functionality/power.md'
- Secrets: 'core-functionality/secrets.md'
- - Tenancy Assignment: 'core-functionality/tenancy.md'
+ - Tenancy: 'core-functionality/tenancy.md'
- Additional Features:
- Caching: 'additional-features/caching.md'
- Change Logging: 'additional-features/change-logging.md'
- - Context Data: 'additional-features/context-data.md'
+ - Context Data: 'models/extras/configcontext.md'
- Custom Fields: 'additional-features/custom-fields.md'
- Custom Links: 'additional-features/custom-links.md'
- Custom Scripts: 'additional-features/custom-scripts.md'
@@ -52,20 +53,20 @@ nav:
- NAPALM: 'additional-features/napalm.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md'
- - Tags: 'additional-features/tags.md'
+ - Tags: 'models/extras/tag.md'
- Webhooks: 'additional-features/webhooks.md'
- Plugins:
- Using Plugins: 'plugins/index.md'
- Developing Plugins: 'plugins/development.md'
- Administration:
+ - Permissions: 'administration/permissions.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'
- - API:
- - Overview: 'api/overview.md'
- - Filtering: 'api/filtering.md'
- - Authentication: 'api/authentication.md'
- - Working with Secrets: 'api/working-with-secrets.md'
- - Examples: 'api/examples.md'
+ - REST API:
+ - Overview: 'rest-api/overview.md'
+ - Filtering: 'rest-api/filtering.md'
+ - Authentication: 'rest-api/authentication.md'
+ - Working with Secrets: 'rest-api/working-with-secrets.md'
- Development:
- Introduction: 'development/index.md'
- Style Guide: 'development/style-guide.md'
diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py
index 6bac48a59..10ae5e5ee 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -1,11 +1,11 @@
from rest_framework import serializers
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.choices import CircuitStatusChoices
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
from dcim.api.serializers import ConnectedEndpointSerializer
from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from .nested_serializers import *
@@ -15,14 +15,14 @@ from .nested_serializers import *
# Providers
#
-class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
- tags = TagListSerializerField(required=False)
+class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = Provider
fields = [
- 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
+ 'id', 'url', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count',
]
@@ -32,11 +32,12 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class CircuitTypeSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = CircuitType
- fields = ['id', 'name', 'slug', 'description', 'circuit_count']
+ fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count']
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
@@ -49,24 +50,25 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
-class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = NestedProviderSerializer()
status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
termination_a = CircuitCircuitTerminationSerializer(read_only=True)
termination_z = CircuitCircuitTerminationSerializer(read_only=True)
- tags = TagListSerializerField(required=False)
class Meta:
model = Circuit
fields = [
- 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
+ 'id', 'url', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
class CircuitTerminationSerializer(ConnectedEndpointSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer()
cable = NestedCableSerializer(read_only=True)
@@ -74,6 +76,6 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer):
class Meta:
model = CircuitTermination
fields = [
- 'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
+ 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
]
diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py
index 01fbfb62c..0bcb2d280 100644
--- a/netbox/circuits/api/urls.py
+++ b/netbox/circuits/api/urls.py
@@ -1,18 +1,9 @@
-from rest_framework import routers
-
+from utilities.api import OrderedDefaultRouter
from . import views
-class CircuitsRootView(routers.APIRootView):
- """
- Circuits API root view
- """
- def get_view_name(self):
- return 'Circuits'
-
-
-router = routers.DefaultRouter()
-router.APIRootView = CircuitsRootView
+router = OrderedDefaultRouter()
+router.APIRootView = views.CircuitsRootView
# Providers
router.register('providers', views.ProviderViewSet)
diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py
index 363392a4d..746ee02f6 100644
--- a/netbox/circuits/api/views.py
+++ b/netbox/circuits/api/views.py
@@ -1,7 +1,8 @@
-from django.db.models import Count
+from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
+from rest_framework.routers import APIRootView
from circuits import filters
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
@@ -12,6 +13,14 @@ from utilities.api import ModelViewSet
from . import serializers
+class CircuitsRootView(APIRootView):
+ """
+ Circuits API root view
+ """
+ def get_view_name(self):
+ return 'Circuits'
+
+
#
# Providers
#
@@ -19,7 +28,7 @@ from . import serializers
class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=Count('circuits')
- )
+ ).order_by(*Provider._meta.ordering)
serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilterSet
@@ -28,8 +37,8 @@ class ProviderViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular provider.
"""
- provider = get_object_or_404(Provider, pk=pk)
- queryset = Graph.objects.filter(type__model='provider')
+ provider = get_object_or_404(self.queryset, pk=pk)
+ queryset = Graph.objects.restrict(request.user).filter(type__model='provider')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
return Response(serializer.data)
@@ -41,7 +50,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
class CircuitTypeViewSet(ModelViewSet):
queryset = CircuitType.objects.annotate(
circuit_count=Count('circuits')
- )
+ ).order_by(*CircuitType._meta.ordering)
serializer_class = serializers.CircuitTypeSerializer
filterset_class = filters.CircuitTypeFilterSet
@@ -52,7 +61,10 @@ class CircuitTypeViewSet(ModelViewSet):
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related(
- 'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device'
+ Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related(
+ 'site', 'connected_endpoint__device'
+ )),
+ 'type', 'tenant', 'provider',
).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilterSet
diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py
index 94a765d11..1b5e69cb5 100644
--- a/netbox/circuits/choices.py
+++ b/netbox/circuits/choices.py
@@ -23,15 +23,6 @@ class CircuitStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONED, 'Decommissioned'),
)
- LEGACY_MAP = {
- STATUS_DEPROVISIONING: 0,
- STATUS_ACTIVE: 1,
- STATUS_PLANNED: 2,
- STATUS_PROVISIONING: 3,
- STATUS_OFFLINE: 4,
- STATUS_DECOMMISSIONED: 5,
- }
-
#
# CircuitTerminations
diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py
index 2185d1eab..5e5a88080 100644
--- a/netbox/circuits/forms.py
+++ b/netbox/circuits/forms.py
@@ -3,14 +3,14 @@ from django import forms
from dcim.models import Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
- TagField,
)
+from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
- APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField,
- CSVModelForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField,
- StaticSelect2, StaticSelect2Multiple, TagFilterField,
+ add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DatePicker,
+ DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
+ StaticSelect2Multiple, TagFilterField,
)
from .choices import CircuitStatusChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -23,7 +23,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
comments = CommentField()
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -105,21 +106,15 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region'
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ query_params={
+ 'region': '$region'
+ }
)
asn = forms.IntegerField(
required=False,
@@ -165,7 +160,8 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=CircuitType.objects.all()
)
comments = CommentField()
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -269,18 +265,12 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
type = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ required=False
)
provider = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ required=False
)
status = forms.MultipleChoiceField(
choices=CircuitStatusChoices,
@@ -290,21 +280,15 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region'
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ query_params={
+ 'region': '$region'
+ }
)
commit_rate = forms.IntegerField(
required=False,
diff --git a/netbox/circuits/migrations/0019_nullbooleanfield_to_booleanfield.py b/netbox/circuits/migrations/0019_nullbooleanfield_to_booleanfield.py
new file mode 100644
index 000000000..c8e844284
--- /dev/null
+++ b/netbox/circuits/migrations/0019_nullbooleanfield_to_booleanfield.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.1b1 on 2020-07-16 15:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('circuits', '0018_standardize_description'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='circuittermination',
+ name='connection_status',
+ field=models.BooleanField(blank=True, null=True),
+ ),
+ ]
diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py
index 57d41a994..cdec41d1f 100644
--- a/netbox/circuits/models.py
+++ b/netbox/circuits/models.py
@@ -6,9 +6,9 @@ from taggit.managers import TaggableManager
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.fields import ASNField
from dcim.models import CableTermination
-from extras.models import CustomFieldModel, ObjectChange, TaggedItem
+from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
-from utilities.models import ChangeLoggedModel
+from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from .choices import *
from .querysets import CircuitQuerySet
@@ -66,9 +66,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
]
@@ -115,6 +116,8 @@ class CircuitType(ChangeLoggedModel):
blank=True,
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'description']
class Meta:
@@ -272,9 +275,10 @@ class CircuitTermination(CableTermination):
blank=True,
null=True
)
- connection_status = models.NullBooleanField(
+ connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
- blank=True
+ blank=True,
+ null=True
)
port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)'
@@ -300,6 +304,8 @@ class CircuitTermination(CableTermination):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
class Meta:
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']
@@ -330,6 +336,9 @@ class CircuitTermination(CableTermination):
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:
- return CircuitTermination.objects.prefetch_related('site').get(circuit=self.circuit, term_side=peer_side)
+ return CircuitTermination.objects.prefetch_related('site').get(
+ circuit=self.circuit,
+ term_side=peer_side
+ )
except CircuitTermination.DoesNotExist:
return None
diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py
index 60956f32a..8a9bd50a4 100644
--- a/netbox/circuits/querysets.py
+++ b/netbox/circuits/querysets.py
@@ -1,7 +1,9 @@
-from django.db.models import OuterRef, QuerySet, Subquery
+from django.db.models import OuterRef, Subquery
+
+from utilities.querysets import RestrictedQuerySet
-class CircuitQuerySet(QuerySet):
+class CircuitQuerySet(RestrictedQuerySet):
def annotate_sites(self):
"""
diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py
index ea17031a1..4aca8688f 100644
--- a/netbox/circuits/tables.py
+++ b/netbox/circuits/tables.py
@@ -2,19 +2,9 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
-from utilities.tables import BaseTable, TagColumn, ToggleColumn
+from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn
from .models import Circuit, CircuitType, Provider
-CIRCUITTYPE_ACTIONS = """
-
-
-
-{% if perms.circuit.change_circuittype %}
-
-{% endif %}
-"""
-
STATUS_LABEL = """
{{ record.get_status_display }}
"""
@@ -53,11 +43,7 @@ class CircuitTypeTable(BaseTable):
circuit_count = tables.Column(
verbose_name='Circuits'
)
- actions = tables.TemplateColumn(
- template_code=CIRCUITTYPE_ACTIONS,
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
- )
+ actions = ButtonsColumn(CircuitType, pk_field='slug')
class Meta(BaseTable.Meta):
model = CircuitType
@@ -76,7 +62,7 @@ class CircuitTable(BaseTable):
)
provider = tables.LinkColumn(
viewname='circuits:provider',
- args=[Accessor('provider.slug')]
+ args=[Accessor('provider__slug')]
)
status = tables.TemplateColumn(
template_code=STATUS_LABEL
diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py
index 73f528905..fa264aaa2 100644
--- a/netbox/circuits/tests/test_api.py
+++ b/netbox/circuits/tests/test_api.py
@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
from django.urls import reverse
from circuits.choices import *
@@ -45,6 +46,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
)
Provider.objects.bulk_create(providers)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_provider_graphs(self):
"""
Test retrieval of Graphs assigned to Providers.
@@ -58,6 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
)
Graph.objects.bulk_create(graphs)
+ self.add_permissions('circuits.view_provider')
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
response = self.client.get(url, **self.header)
diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py
index 9cc7af6ae..3356fca8f 100644
--- a/netbox/circuits/tests/test_views.py
+++ b/netbox/circuits/tests/test_views.py
@@ -17,6 +17,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Provider(name='Provider 3', slug='provider-3', asn=65003),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Provider X',
'slug': 'provider-x',
@@ -26,7 +28,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'noc_contact': 'noc@example.com',
'admin_contact': 'admin@example.com',
'comments': 'Another provider',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -96,6 +98,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'cid': 'Circuit X',
'provider': providers[1].pk,
@@ -106,7 +110,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 1000,
'description': 'A new circuit',
'comments': 'Some comments',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -124,5 +128,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 2000,
'description': 'New description',
'comments': 'New comments',
-
}
diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py
index 72d9720df..86ea55fa8 100644
--- a/netbox/circuits/urls.py
+++ b/netbox/circuits/urls.py
@@ -10,7 +10,7 @@ urlpatterns = [
# Providers
path('providers/', views.ProviderListView.as_view(), name='provider_list'),
- path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
+ path('providers/add/', views.ProviderEditView.as_view(), name='provider_add'),
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
@@ -21,15 +21,16 @@ urlpatterns = [
# Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
- path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
+ path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
+ path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
- path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
+ path('circuits/add/', views.CircuitEditView.as_view(), name='circuit_add'),
path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
@@ -37,11 +38,10 @@ urlpatterns = [
path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
- path('circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
+ path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
# Circuit terminations
-
- path('circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
+ path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 709d2a726..4d02ef011 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -1,18 +1,15 @@
from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.decorators import permission_required
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
-from django.db.models import Count, OuterRef, Subquery
+from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404, redirect, render
-from django.views.generic import View
from django_tables2 import RequestConfig
from extras.models import Graph
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
- BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices
@@ -23,21 +20,20 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers
#
-class ProviderListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'circuits.view_provider'
- queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
+class ProviderListView(ObjectListView):
+ queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm
table = tables.ProviderTable
-class ProviderView(PermissionRequiredMixin, View):
- permission_required = 'circuits.view_provider'
+class ProviderView(ObjectView):
+ queryset = Provider.objects.all()
def get(self, request, slug):
- provider = get_object_or_404(Provider, slug=slug)
- circuits = Circuit.objects.filter(
+ provider = get_object_or_404(self.queryset, slug=slug)
+ circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=provider
).prefetch_related(
'type', 'tenant', 'terminations__site'
@@ -60,114 +56,98 @@ class ProviderView(PermissionRequiredMixin, View):
})
-class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'circuits.add_provider'
- model = Provider
+class ProviderEditView(ObjectEditView):
+ queryset = Provider.objects.all()
model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
- default_return_url = 'circuits:provider_list'
-class ProviderEditView(ProviderCreateView):
- permission_required = 'circuits.change_provider'
+class ProviderDeleteView(ObjectDeleteView):
+ queryset = Provider.objects.all()
-class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'circuits.delete_provider'
- model = Provider
- default_return_url = 'circuits:provider_list'
-
-
-class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'circuits.add_provider'
+class ProviderBulkImportView(BulkImportView):
+ queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm
table = tables.ProviderTable
- default_return_url = 'circuits:provider_list'
-class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'circuits.change_provider'
- queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
+class ProviderBulkEditView(BulkEditView):
+ queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
form = forms.ProviderBulkEditForm
- default_return_url = 'circuits:provider_list'
-class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'circuits.delete_provider'
- queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
+class ProviderBulkDeleteView(BulkDeleteView):
+ queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
- default_return_url = 'circuits:provider_list'
#
# Circuit Types
#
-class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'circuits.view_circuittype'
- queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
+class CircuitTypeListView(ObjectListView):
+ queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
table = tables.CircuitTypeTable
-class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'circuits.add_circuittype'
- model = CircuitType
+class CircuitTypeEditView(ObjectEditView):
+ queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm
- default_return_url = 'circuits:circuittype_list'
-class CircuitTypeEditView(CircuitTypeCreateView):
- permission_required = 'circuits.change_circuittype'
+class CircuitTypeDeleteView(ObjectDeleteView):
+ queryset = CircuitType.objects.all()
-class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'circuits.add_circuittype'
+class CircuitTypeBulkImportView(BulkImportView):
+ queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable
- default_return_url = 'circuits:circuittype_list'
-class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'circuits.delete_circuittype'
- queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
+class CircuitTypeBulkDeleteView(BulkDeleteView):
+ queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
table = tables.CircuitTypeTable
- default_return_url = 'circuits:circuittype_list'
#
# Circuits
#
-class CircuitListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'circuits.view_circuit'
- _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
+class CircuitListView(ObjectListView):
queryset = Circuit.objects.prefetch_related(
- 'provider', 'type', 'tenant', 'terminations__site'
+ 'provider', 'type', 'tenant', 'terminations'
).annotate_sites()
filterset = filters.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
table = tables.CircuitTable
-class CircuitView(PermissionRequiredMixin, View):
- permission_required = 'circuits.view_circuit'
+class CircuitView(ObjectView):
+ queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
def get(self, request, pk):
+ circuit = get_object_or_404(self.queryset, pk=pk)
- circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk)
- termination_a = CircuitTermination.objects.prefetch_related(
+ termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
- termination_z = CircuitTermination.objects.prefetch_related(
+ if termination_a and termination_a.connected_endpoint:
+ termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
+
+ termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
).first()
+ if termination_z and termination_z.connected_endpoint:
+ termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
@@ -176,67 +156,80 @@ class CircuitView(PermissionRequiredMixin, View):
})
-class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'circuits.add_circuit'
- model = Circuit
+class CircuitEditView(ObjectEditView):
+ queryset = Circuit.objects.all()
model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html'
- default_return_url = 'circuits:circuit_list'
-class CircuitEditView(CircuitCreateView):
- permission_required = 'circuits.change_circuit'
+class CircuitDeleteView(ObjectDeleteView):
+ queryset = Circuit.objects.all()
-class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'circuits.delete_circuit'
- model = Circuit
- default_return_url = 'circuits:circuit_list'
-
-
-class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'circuits.add_circuit'
+class CircuitBulkImportView(BulkImportView):
+ queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm
table = tables.CircuitTable
- default_return_url = 'circuits:circuit_list'
-class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'circuits.change_circuit'
- queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
+class CircuitBulkEditView(BulkEditView):
+ queryset = Circuit.objects.prefetch_related(
+ 'provider', 'type', 'tenant', 'terminations'
+ )
filterset = filters.CircuitFilterSet
table = tables.CircuitTable
form = forms.CircuitBulkEditForm
- default_return_url = 'circuits:circuit_list'
-class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'circuits.delete_circuit'
- queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
+class CircuitBulkDeleteView(BulkDeleteView):
+ queryset = Circuit.objects.prefetch_related(
+ 'provider', 'type', 'tenant', 'terminations'
+ )
filterset = filters.CircuitFilterSet
table = tables.CircuitTable
- default_return_url = 'circuits:circuit_list'
-@permission_required('circuits.change_circuittermination')
-def circuit_terminations_swap(request, pk):
+class CircuitSwapTerminations(ObjectEditView):
+ """
+ Swap the A and Z terminations of a circuit.
+ """
+ queryset = Circuit.objects.all()
- circuit = get_object_or_404(Circuit, pk=pk)
- termination_a = CircuitTermination.objects.filter(
- circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
- ).first()
- termination_z = CircuitTermination.objects.filter(
- circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
- ).first()
- if not termination_a and not termination_z:
- messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
- return redirect('circuits:circuit', pk=circuit.pk)
+ def get(self, request, pk):
+ circuit = get_object_or_404(self.queryset, pk=pk)
+ form = ConfirmationForm()
- if request.method == 'POST':
+ # Circuit must have at least one termination to swap
+ if not circuit.termination_a and not circuit.termination_z:
+ messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
+ return redirect('circuits:circuit', pk=circuit.pk)
+
+ return render(request, 'circuits/circuit_terminations_swap.html', {
+ 'circuit': circuit,
+ 'termination_a': circuit.termination_a,
+ 'termination_z': circuit.termination_z,
+ 'form': form,
+ 'panel_class': 'default',
+ 'button_class': 'primary',
+ 'return_url': circuit.get_absolute_url(),
+ })
+
+ def post(self, request, pk):
+ circuit = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm(request.POST)
+
if form.is_valid():
+
+ termination_a = CircuitTermination.objects.filter(
+ circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
+ ).first()
+ termination_z = CircuitTermination.objects.filter(
+ circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
+ ).first()
+
if termination_a and termination_z:
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
+ print('swapping')
with transaction.atomic():
termination_a.term_side = '_'
termination_a.save()
@@ -250,30 +243,27 @@ def circuit_terminations_swap(request, pk):
else:
termination_z.term_side = 'A'
termination_z.save()
+
messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
- else:
- form = ConfirmationForm()
-
- return render(request, 'circuits/circuit_terminations_swap.html', {
- 'circuit': circuit,
- 'termination_a': termination_a,
- 'termination_z': termination_z,
- 'form': form,
- 'panel_class': 'default',
- 'button_class': 'primary',
- 'return_url': circuit.get_absolute_url(),
- })
+ return render(request, 'circuits/circuit_terminations_swap.html', {
+ 'circuit': circuit,
+ 'termination_a': circuit.termination_a,
+ 'termination_z': circuit.termination_z,
+ 'form': form,
+ 'panel_class': 'default',
+ 'button_class': 'primary',
+ 'return_url': circuit.get_absolute_url(),
+ })
#
# Circuit terminations
#
-class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'circuits.add_circuittermination'
- model = CircuitTermination
+class CircuitTerminationEditView(ObjectEditView):
+ queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
@@ -286,10 +276,5 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
return obj.circuit.get_absolute_url()
-class CircuitTerminationEditView(CircuitTerminationCreateView):
- permission_required = 'circuits.change_circuittermination'
-
-
-class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'circuits.delete_circuittermination'
- model = CircuitTermination
+class CircuitTerminationDeleteView(ObjectDeleteView):
+ queryset = CircuitTermination.objects.all()
diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py
index 83fcd7a2a..3bc953991 100644
--- a/netbox/dcim/api/nested_serializers.py
+++ b/netbox/dcim/api/nested_serializers.py
@@ -47,10 +47,11 @@ __all__ = [
class NestedRegionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
site_count = serializers.IntegerField(read_only=True)
+ _depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.Region
- fields = ['id', 'url', 'name', 'slug', 'site_count']
+ fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth']
class NestedSiteSerializer(WritableNestedSerializer):
@@ -68,10 +69,11 @@ class NestedSiteSerializer(WritableNestedSerializer):
class NestedRackGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
rack_count = serializers.IntegerField(read_only=True)
+ _depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.RackGroup
- fields = ['id', 'url', 'name', 'slug', 'rack_count']
+ fields = ['id', 'url', 'name', 'slug', 'rack_count', '_depth']
class NestedRackRoleSerializer(WritableNestedSerializer):
@@ -332,7 +334,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
class Meta:
model = models.VirtualChassis
- fields = ['id', 'url', 'master', 'member_count']
+ fields = ['id', 'name', 'url', 'master', 'member_count']
#
@@ -353,4 +355,4 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
class Meta:
model = models.PowerFeed
- fields = ['id', 'url', 'name']
+ fields = ['id', 'url', 'name', 'cable']
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 8b19149b0..50c1f99ff 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
-from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.choices import *
from dcim.constants import *
@@ -15,6 +14,7 @@ from dcim.models import (
VirtualChassis,
)
from extras.api.customfields import CustomFieldModelSerializer
+from extras.api.serializers import TaggedObjectSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -60,20 +60,22 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
#
class RegionSerializer(serializers.ModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
parent = NestedRegionSerializer(required=False, allow_null=True)
site_count = serializers.IntegerField(read_only=True)
+ _depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = Region
- fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count']
+ fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'site_count', '_depth']
-class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
status = ChoiceField(choices=SiteStatusChoices, required=False)
region = NestedRegionSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False)
- tags = TagListSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
@@ -84,7 +86,7 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
class Meta:
model = Site
fields = [
- 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
+ 'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
@@ -96,24 +98,28 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class RackGroupSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
site = NestedSiteSerializer()
parent = NestedRackGroupSerializer(required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
+ _depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = RackGroup
- fields = ['id', 'name', 'slug', 'site', 'parent', 'description', 'rack_count']
+ fields = ['id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'rack_count', '_depth']
class RackRoleSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackRole
- fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count']
+ fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'rack_count']
-class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -122,14 +128,13 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
- tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True)
class Meta:
model = Rack
fields = [
- 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
+ 'id', 'url', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
@@ -160,16 +165,18 @@ class RackUnitSerializer(serializers.Serializer):
name = serializers.CharField(read_only=True)
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
device = NestedDeviceSerializer(read_only=True)
+ occupied = serializers.BooleanField(read_only=True)
-class RackReservationSerializer(ValidatedModelSerializer):
+class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
rack = NestedRackSerializer()
user = NestedUserSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
class Meta:
model = RackReservation
- fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
+ fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags']
class RackElevationDetailFilterSerializer(serializers.Serializer):
@@ -213,6 +220,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
#
class ManufacturerSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True)
inventoryitem_count = serializers.IntegerField(read_only=True)
platform_count = serializers.IntegerField(read_only=True)
@@ -220,26 +228,27 @@ class ManufacturerSerializer(ValidatedModelSerializer):
class Meta:
model = Manufacturer
fields = [
- 'id', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count',
+ 'id', 'url', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count',
]
-class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
- tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceType
fields = [
- 'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
+ 'id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'device_count',
]
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@@ -249,10 +258,11 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
- fields = ['id', 'device_type', 'name', 'type']
+ fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description']
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@@ -262,10 +272,11 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
- fields = ['id', 'device_type', 'name', 'type']
+ fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description']
class PowerPortTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
@@ -275,10 +286,11 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
- fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw']
+ fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
@@ -296,43 +308,47 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
- fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg']
+ fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
class InterfaceTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
class Meta:
model = InterfaceTemplate
- fields = ['id', 'device_type', 'name', 'type', 'mgmt_only']
+ fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description']
class RearPortTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=PortTypeChoices)
class Meta:
model = RearPortTemplate
- fields = ['id', 'device_type', 'name', 'type', 'positions']
+ fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description']
class FrontPortTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=PortTypeChoices)
rear_port = NestedRearPortTemplateSerializer()
class Meta:
model = FrontPortTemplate
- fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position']
+ fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description']
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
device_type = NestedDeviceTypeSerializer()
class Meta:
model = DeviceBayTemplate
- fields = ['id', 'device_type', 'name']
+ fields = ['id', 'url', 'device_type', 'name', 'label', 'description']
#
@@ -340,17 +356,19 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
#
class DeviceRoleSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceRole
fields = [
- 'id', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count',
+ 'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count',
]
class PlatformSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
@@ -358,12 +376,13 @@ class PlatformSerializer(ValidatedModelSerializer):
class Meta:
model = Platform
fields = [
- 'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count',
+ 'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count',
'virtualmachine_count',
]
-class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -378,15 +397,14 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True)
- tags = TagListSerializerField(required=False)
class Meta:
model = Device
fields = [
- 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
- 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
- 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags',
- 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
+ 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
+ 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
+ 'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
@@ -419,10 +437,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
fields = [
- 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
- 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
- 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags',
- 'custom_fields', 'config_context', 'created', 'last_updated',
+ 'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
+ 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
+ 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
+ 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@@ -434,7 +452,8 @@ class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField()
-class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@@ -442,17 +461,17 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
required=False
)
cable = NestedCableSerializer(read_only=True)
- tags = TagListSerializerField(required=False)
class Meta:
model = ConsoleServerPort
fields = [
- 'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
- 'connection_status', 'cable', 'tags',
+ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
+ 'connected_endpoint', 'connection_status', 'cable', 'tags',
]
-class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@@ -460,17 +479,17 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
required=False
)
cable = NestedCableSerializer(read_only=True)
- tags = TagListSerializerField(required=False)
class Meta:
model = ConsolePort
fields = [
- 'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
- 'connection_status', 'cable', 'tags',
+ 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
+ 'connected_endpoint', 'connection_status', 'cable', 'tags',
]
-class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
@@ -488,19 +507,17 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
cable = NestedCableSerializer(
read_only=True
)
- tags = TagListSerializerField(
- required=False
- )
class Meta:
model = PowerOutlet
fields = [
- 'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
- 'connected_endpoint', 'connection_status', 'cable', 'tags',
+ 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
+ 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
]
-class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
@@ -508,17 +525,17 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
required=False
)
cable = NestedCableSerializer(read_only=True)
- tags = TagListSerializerField(required=False)
class Meta:
model = PowerPort
fields = [
- 'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
- 'connected_endpoint', 'connection_status', 'cable', 'tags',
+ 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+ 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
]
-class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
@@ -531,15 +548,14 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
many=True
)
cable = NestedCableSerializer(read_only=True)
- tags = TagListSerializerField(required=False)
count_ipaddresses = serializers.IntegerField(read_only=True)
class Meta:
model = Interface
fields = [
- 'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
- 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
- 'tagged_vlans', 'tags', 'count_ipaddresses',
+ 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
+ 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
+ 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
]
# TODO: This validation should be handled by Interface.clean()
@@ -563,15 +579,15 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
return super().validate(data)
-class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
+class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
cable = NestedCableSerializer(read_only=True)
- tags = TagListSerializerField(required=False)
class Meta:
model = RearPort
- fields = ['id', 'device', 'name', 'type', 'positions', 'description', 'cable', 'tags']
+ fields = ['id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags']
class FrontPortRearPortSerializer(WritableNestedSerializer):
@@ -582,47 +598,50 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
class Meta:
model = RearPort
- fields = ['id', 'url', 'name']
+ fields = ['id', 'url', 'name', 'label']
-class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
+class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True)
- tags = TagListSerializerField(required=False)
class Meta:
model = FrontPort
- fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags']
+ fields = [
+ 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
+ 'tags',
+ ]
-class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
+class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
- tags = TagListSerializerField(required=False)
class Meta:
model = DeviceBay
- fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
+ fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags']
#
# Inventory items
#
-class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
+class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
- tags = TagListSerializerField(required=False)
class Meta:
model = InventoryItem
fields = [
- 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
- 'description', 'tags',
+ 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag',
+ 'discovered', 'description', 'tags',
]
@@ -630,7 +649,8 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
# Cables
#
-class CableSerializer(ValidatedModelSerializer):
+class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
@@ -645,8 +665,8 @@ class CableSerializer(ValidatedModelSerializer):
class Meta:
model = Cable
fields = [
- 'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id',
- 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit',
+ 'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
+ 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
]
def _get_termination(self, obj, side):
@@ -709,21 +729,22 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
# Virtual chassis
#
-class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
- master = NestedDeviceSerializer()
- tags = TagListSerializerField(required=False)
+class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
+ master = NestedDeviceSerializer(required=False)
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
- fields = ['id', 'master', 'domain', 'tags', 'member_count']
+ fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'member_count']
#
# Power panels
#
-class PowerPanelSerializer(ValidatedModelSerializer):
+class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
site = NestedSiteSerializer()
rack_group = NestedRackGroupSerializer(
required=False,
@@ -734,10 +755,11 @@ class PowerPanelSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPanel
- fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count']
+ fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count']
-class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
+class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
required=False,
@@ -760,13 +782,11 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
- tags = TagListSerializerField(
- required=False
- )
+ cable = NestedCableSerializer(read_only=True)
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',
+ 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
+ 'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'cable',
]
diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py
index f989d817c..e8c4fbe1d 100644
--- a/netbox/dcim/api/urls.py
+++ b/netbox/dcim/api/urls.py
@@ -1,18 +1,9 @@
-from rest_framework import routers
-
+from utilities.api import OrderedDefaultRouter
from . import views
-class DCIMRootView(routers.APIRootView):
- """
- DCIM API root view
- """
- def get_view_name(self):
- return 'DCIM'
-
-
-router = routers.DefaultRouter()
-router.APIRootView = DCIMRootView
+router = OrderedDefaultRouter()
+router.APIRootView = views.DCIMRootView
# Sites
router.register('regions', views.RegionViewSet)
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 2aff33840..f5b37021d 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -11,6 +11,7 @@ from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin
from rest_framework.response import Response
+from rest_framework.routers import APIRootView
from rest_framework.viewsets import GenericViewSet, ViewSet
from circuits.models import Circuit
@@ -36,6 +37,14 @@ from . import serializers
from .exceptions import MissingFilterException
+class DCIMRootView(APIRootView):
+ """
+ DCIM API root view
+ """
+ def get_view_name(self):
+ return 'DCIM'
+
+
# Mixins
class CableTraceMixin(object):
@@ -45,7 +54,7 @@ class CableTraceMixin(object):
"""
Trace a complete cable path and return each segment as a three-tuple of (termination, cable, termination).
"""
- obj = get_object_or_404(self.queryset.model, pk=pk)
+ obj = get_object_or_404(self.queryset, pk=pk)
# Initialize the path array
path = []
@@ -75,8 +84,12 @@ class CableTraceMixin(object):
#
class RegionViewSet(ModelViewSet):
- queryset = Region.objects.annotate(
- site_count=Count('sites')
+ queryset = Region.objects.add_related_count(
+ Region.objects.all(),
+ Site,
+ 'region',
+ 'site_count',
+ cumulative=True
)
serializer_class = serializers.RegionSerializer
filterset_class = filters.RegionFilterSet
@@ -96,7 +109,7 @@ class SiteViewSet(CustomFieldModelViewSet):
vlan_count=get_subquery(VLAN, 'site'),
circuit_count=get_subquery(Circuit, 'terminations__site'),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
- )
+ ).order_by(*Site._meta.ordering)
serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilterSet
@@ -105,8 +118,8 @@ class SiteViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular site.
"""
- site = get_object_or_404(Site, pk=pk)
- queryset = Graph.objects.filter(type__model='site')
+ site = get_object_or_404(self.queryset, pk=pk)
+ queryset = Graph.objects.restrict(request.user).filter(type__model='site')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
return Response(serializer.data)
@@ -116,9 +129,13 @@ class SiteViewSet(CustomFieldModelViewSet):
#
class RackGroupViewSet(ModelViewSet):
- queryset = RackGroup.objects.prefetch_related('site').annotate(
- rack_count=Count('racks')
- )
+ queryset = RackGroup.objects.add_related_count(
+ RackGroup.objects.all(),
+ Rack,
+ 'group',
+ 'rack_count',
+ cumulative=True
+ ).prefetch_related('site')
serializer_class = serializers.RackGroupSerializer
filterset_class = filters.RackGroupFilterSet
@@ -130,7 +147,7 @@ class RackGroupViewSet(ModelViewSet):
class RackRoleViewSet(ModelViewSet):
queryset = RackRole.objects.annotate(
rack_count=Count('racks')
- )
+ ).order_by(*RackRole._meta.ordering)
serializer_class = serializers.RackRoleSerializer
filterset_class = filters.RackRoleFilterSet
@@ -145,7 +162,7 @@ class RackViewSet(CustomFieldModelViewSet):
).annotate(
device_count=get_subquery(Device, 'rack'),
powerfeed_count=get_subquery(PowerFeed, 'rack')
- )
+ ).order_by(*Rack._meta.ordering)
serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilterSet
@@ -158,7 +175,7 @@ class RackViewSet(CustomFieldModelViewSet):
"""
Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG.
"""
- rack = get_object_or_404(Rack, pk=pk)
+ rack = get_object_or_404(self.queryset, pk=pk)
serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET)
if not serializer.is_valid():
return Response(serializer.errors, 400)
@@ -168,6 +185,7 @@ class RackViewSet(CustomFieldModelViewSet):
# Render and return the elevation as an SVG drawing with the correct content type
drawing = rack.get_elevation_svg(
face=data['face'],
+ user=request.user,
unit_width=data['unit_width'],
unit_height=data['unit_height'],
legend_width=data['legend_width'],
@@ -180,6 +198,7 @@ class RackViewSet(CustomFieldModelViewSet):
# Return a JSON representation of the rack units in the elevation
elevation = rack.get_rack_units(
face=data['face'],
+ user=request.user,
exclude=data['exclude'],
expand_devices=data['expand_devices']
)
@@ -218,7 +237,7 @@ class ManufacturerViewSet(ModelViewSet):
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer')
- )
+ ).order_by(*Manufacturer._meta.ordering)
serializer_class = serializers.ManufacturerSerializer
filterset_class = filters.ManufacturerFilterSet
@@ -228,9 +247,9 @@ class ManufacturerViewSet(ModelViewSet):
#
class DeviceTypeViewSet(CustomFieldModelViewSet):
- queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate(
+ queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=Count('instances')
- )
+ ).order_by(*DeviceType._meta.ordering)
serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilterSet
@@ -295,7 +314,7 @@ class DeviceRoleViewSet(ModelViewSet):
queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
virtualmachine_count=get_subquery(VirtualMachine, 'role')
- )
+ ).order_by(*DeviceRole._meta.ordering)
serializer_class = serializers.DeviceRoleSerializer
filterset_class = filters.DeviceRoleFilterSet
@@ -308,7 +327,7 @@ class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'),
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
- )
+ ).order_by(*Platform._meta.ordering)
serializer_class = serializers.PlatformSerializer
filterset_class = filters.PlatformFilterSet
@@ -349,8 +368,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular Device.
"""
- device = get_object_or_404(Device, pk=pk)
- queryset = Graph.objects.filter(type__model='device')
+ device = get_object_or_404(self.queryset, pk=pk)
+ queryset = Graph.objects.restrict(request.user).filter(type__model='device')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
return Response(serializer.data)
@@ -371,7 +390,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
"""
Execute a NAPALM method on a Device
"""
- device = get_object_or_404(Device, pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
+ if not device.primary_ip:
+ raise ServiceUnavailable("This device does not have a primary IP address configured.")
if device.platform is None:
raise ServiceUnavailable("No platform is configured for this device.")
if not device.platform.napalm_driver:
@@ -411,7 +432,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
))
# Verify user permission
- if not request.user.has_perm('dcim.napalm_read'):
+ if not request.user.has_perm('dcim.napalm_read_device'):
return HttpResponseForbidden()
napalm_methods = request.GET.getlist('method')
@@ -511,8 +532,8 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
"""
A convenience method for rendering graphs for a particular interface.
"""
- interface = get_object_or_404(Interface, pk=pk)
- queryset = Graph.objects.filter(type__model='interface')
+ interface = get_object_or_404(self.queryset, pk=pk)
+ queryset = Graph.objects.restrict(request.user).filter(type__model='interface')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface})
return Response(serializer.data)
@@ -596,8 +617,8 @@ class CableViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
- member_count=Count('members')
- )
+ member_count=Count('members', distinct=True)
+ ).order_by(*VirtualChassis._meta.ordering)
serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilterSet
@@ -611,7 +632,7 @@ class PowerPanelViewSet(ModelViewSet):
'site', 'rack_group'
).annotate(
powerfeed_count=Count('powerfeeds')
- )
+ ).order_by(*PowerPanel._meta.ordering)
serializer_class = serializers.PowerPanelSerializer
filterset_class = filters.PowerPanelFilterSet
@@ -671,7 +692,11 @@ class ConnectedDeviceViewSet(ViewSet):
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
# Determine local interface from peer interface's connection
- peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)
+ peer_interface = get_object_or_404(
+ Interface.objects.all(),
+ device__name=peer_device_name,
+ name=peer_interface_name
+ )
local_interface = peer_interface._connected_interface
if local_interface is None:
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 4e2ef1388..dc12e686e 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -21,12 +21,6 @@ class SiteStatusChoices(ChoiceSet):
(STATUS_RETIRED, 'Retired'),
)
- LEGACY_MAP = {
- STATUS_ACTIVE: 1,
- STATUS_PLANNED: 2,
- STATUS_RETIRED: 4,
- }
-
#
# Racks
@@ -48,14 +42,6 @@ class RackTypeChoices(ChoiceSet):
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
)
- LEGACY_MAP = {
- TYPE_2POST: 100,
- TYPE_4POST: 200,
- TYPE_CABINET: 300,
- TYPE_WALLFRAME: 1000,
- TYPE_WALLCABINET: 1100,
- }
-
class RackWidthChoices(ChoiceSet):
@@ -88,14 +74,6 @@ class RackStatusChoices(ChoiceSet):
(STATUS_DEPRECATED, 'Deprecated'),
)
- LEGACY_MAP = {
- STATUS_RESERVED: 0,
- STATUS_AVAILABLE: 1,
- STATUS_PLANNED: 2,
- STATUS_ACTIVE: 3,
- STATUS_DEPRECATED: 4,
- }
-
class RackDimensionUnitChoices(ChoiceSet):
@@ -107,11 +85,6 @@ class RackDimensionUnitChoices(ChoiceSet):
(UNIT_INCH, 'Inches'),
)
- LEGACY_MAP = {
- UNIT_MILLIMETER: 1000,
- UNIT_INCH: 2000,
- }
-
class RackElevationDetailRenderChoices(ChoiceSet):
@@ -138,11 +111,6 @@ class SubdeviceRoleChoices(ChoiceSet):
(ROLE_CHILD, 'Child'),
)
- LEGACY_MAP = {
- ROLE_PARENT: True,
- ROLE_CHILD: False,
- }
-
#
# Devices
@@ -158,11 +126,6 @@ class DeviceFaceChoices(ChoiceSet):
(FACE_REAR, 'Rear'),
)
- LEGACY_MAP = {
- FACE_FRONT: 0,
- FACE_REAR: 1,
- }
-
class DeviceStatusChoices(ChoiceSet):
@@ -184,16 +147,6 @@ class DeviceStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONING, 'Decommissioning'),
)
- LEGACY_MAP = {
- STATUS_OFFLINE: 0,
- STATUS_ACTIVE: 1,
- STATUS_PLANNED: 2,
- STATUS_STAGED: 3,
- STATUS_FAILED: 4,
- STATUS_INVENTORY: 5,
- STATUS_DECOMMISSIONING: 6,
- }
-
#
# ConsolePorts
@@ -611,12 +564,6 @@ class PowerOutletFeedLegChoices(ChoiceSet):
(FEED_LEG_C, 'C'),
)
- LEGACY_MAP = {
- FEED_LEG_A: 1,
- FEED_LEG_B: 2,
- FEED_LEG_C: 3,
- }
-
#
# Interfaces
@@ -846,80 +793,6 @@ class InterfaceTypeChoices(ChoiceSet):
),
)
- LEGACY_MAP = {
- TYPE_VIRTUAL: 0,
- TYPE_LAG: 200,
- TYPE_100ME_FIXED: 800,
- TYPE_1GE_FIXED: 1000,
- TYPE_1GE_GBIC: 1050,
- TYPE_1GE_SFP: 1100,
- TYPE_2GE_FIXED: 1120,
- TYPE_5GE_FIXED: 1130,
- TYPE_10GE_FIXED: 1150,
- TYPE_10GE_CX4: 1170,
- TYPE_10GE_SFP_PLUS: 1200,
- TYPE_10GE_XFP: 1300,
- TYPE_10GE_XENPAK: 1310,
- TYPE_10GE_X2: 1320,
- TYPE_25GE_SFP28: 1350,
- TYPE_40GE_QSFP_PLUS: 1400,
- TYPE_50GE_QSFP28: 1420,
- TYPE_100GE_CFP: 1500,
- TYPE_100GE_CFP2: 1510,
- TYPE_100GE_CFP4: 1520,
- TYPE_100GE_CPAK: 1550,
- TYPE_100GE_QSFP28: 1600,
- TYPE_200GE_CFP2: 1650,
- TYPE_200GE_QSFP56: 1700,
- TYPE_400GE_QSFP_DD: 1750,
- TYPE_400GE_OSFP: 1800,
- TYPE_80211A: 2600,
- TYPE_80211G: 2610,
- TYPE_80211N: 2620,
- TYPE_80211AC: 2630,
- TYPE_80211AD: 2640,
- TYPE_GSM: 2810,
- TYPE_CDMA: 2820,
- TYPE_LTE: 2830,
- TYPE_SONET_OC3: 6100,
- TYPE_SONET_OC12: 6200,
- TYPE_SONET_OC48: 6300,
- TYPE_SONET_OC192: 6400,
- TYPE_SONET_OC768: 6500,
- TYPE_SONET_OC1920: 6600,
- TYPE_SONET_OC3840: 6700,
- TYPE_1GFC_SFP: 3010,
- TYPE_2GFC_SFP: 3020,
- TYPE_4GFC_SFP: 3040,
- TYPE_8GFC_SFP_PLUS: 3080,
- TYPE_16GFC_SFP_PLUS: 3160,
- TYPE_32GFC_SFP28: 3320,
- TYPE_128GFC_QSFP28: 3400,
- TYPE_INFINIBAND_SDR: 7010,
- TYPE_INFINIBAND_DDR: 7020,
- TYPE_INFINIBAND_QDR: 7030,
- TYPE_INFINIBAND_FDR10: 7040,
- TYPE_INFINIBAND_FDR: 7050,
- TYPE_INFINIBAND_EDR: 7060,
- TYPE_INFINIBAND_HDR: 7070,
- TYPE_INFINIBAND_NDR: 7080,
- TYPE_INFINIBAND_XDR: 7090,
- TYPE_T1: 4000,
- TYPE_E1: 4010,
- TYPE_T3: 4040,
- TYPE_E3: 4050,
- TYPE_STACKWISE: 5000,
- TYPE_STACKWISE_PLUS: 5050,
- TYPE_FLEXSTACK: 5100,
- TYPE_FLEXSTACK_PLUS: 5150,
- TYPE_JUNIPER_VCP: 5200,
- TYPE_SUMMITSTACK: 5300,
- TYPE_SUMMITSTACK128: 5310,
- TYPE_SUMMITSTACK256: 5320,
- TYPE_SUMMITSTACK512: 5330,
- TYPE_OTHER: 32767,
- }
-
class InterfaceModeChoices(ChoiceSet):
@@ -933,12 +806,6 @@ class InterfaceModeChoices(ChoiceSet):
(MODE_TAGGED_ALL, 'Tagged (All)'),
)
- LEGACY_MAP = {
- MODE_ACCESS: 100,
- MODE_TAGGED: 200,
- MODE_TAGGED_ALL: 300,
- }
-
#
# FrontPorts/RearPorts
@@ -988,22 +855,6 @@ class PortTypeChoices(ChoiceSet):
)
)
- LEGACY_MAP = {
- TYPE_8P8C: 1000,
- TYPE_110_PUNCH: 1100,
- TYPE_BNC: 1200,
- TYPE_ST: 2000,
- TYPE_SC: 2100,
- TYPE_SC_APC: 2110,
- TYPE_FC: 2200,
- TYPE_LC: 2300,
- TYPE_LC_APC: 2310,
- TYPE_MTRJ: 2400,
- TYPE_MPO: 2500,
- TYPE_LSH: 2600,
- TYPE_LSH_APC: 2610,
- }
-
#
# Cables
@@ -1063,28 +914,6 @@ class CableTypeChoices(ChoiceSet):
(TYPE_POWER, 'Power'),
)
- LEGACY_MAP = {
- TYPE_CAT3: 1300,
- TYPE_CAT5: 1500,
- TYPE_CAT5E: 1510,
- TYPE_CAT6: 1600,
- TYPE_CAT6A: 1610,
- TYPE_CAT7: 1700,
- TYPE_DAC_ACTIVE: 1800,
- TYPE_DAC_PASSIVE: 1810,
- TYPE_COAXIAL: 1900,
- TYPE_MMF: 3000,
- TYPE_MMF_OM1: 3010,
- TYPE_MMF_OM2: 3020,
- TYPE_MMF_OM3: 3030,
- TYPE_MMF_OM4: 3040,
- TYPE_SMF: 3500,
- TYPE_SMF_OS1: 3510,
- TYPE_SMF_OS2: 3520,
- TYPE_AOC: 3800,
- TYPE_POWER: 5000,
- }
-
class CableStatusChoices(ChoiceSet):
@@ -1098,11 +927,6 @@ class CableStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONING, 'Decommissioning'),
)
- LEGACY_MAP = {
- STATUS_CONNECTED: True,
- STATUS_PLANNED: False,
- }
-
class CableLengthUnitChoices(ChoiceSet):
@@ -1118,13 +942,6 @@ class CableLengthUnitChoices(ChoiceSet):
(UNIT_INCH, 'Inches'),
)
- LEGACY_MAP = {
- UNIT_METER: 1200,
- UNIT_CENTIMETER: 1100,
- UNIT_FOOT: 2100,
- UNIT_INCH: 2000,
- }
-
#
# PowerFeeds
@@ -1144,13 +961,6 @@ class PowerFeedStatusChoices(ChoiceSet):
(STATUS_FAILED, 'Failed'),
)
- LEGACY_MAP = {
- STATUS_OFFLINE: 0,
- STATUS_ACTIVE: 1,
- STATUS_PLANNED: 2,
- STATUS_FAILED: 4,
- }
-
class PowerFeedTypeChoices(ChoiceSet):
@@ -1162,11 +972,6 @@ class PowerFeedTypeChoices(ChoiceSet):
(TYPE_REDUNDANT, 'Redundant'),
)
- LEGACY_MAP = {
- TYPE_PRIMARY: 1,
- TYPE_REDUNDANT: 2,
- }
-
class PowerFeedSupplyChoices(ChoiceSet):
@@ -1178,11 +983,6 @@ class PowerFeedSupplyChoices(ChoiceSet):
(SUPPLY_DC, 'DC'),
)
- LEGACY_MAP = {
- SUPPLY_AC: 1,
- SUPPLY_DC: 2,
- }
-
class PowerFeedPhaseChoices(ChoiceSet):
@@ -1193,8 +993,3 @@ class PowerFeedPhaseChoices(ChoiceSet):
(PHASE_SINGLE, 'Single phase'),
(PHASE_3PHASE, 'Three-phase'),
)
-
- LEGACY_MAP = {
- PHASE_SINGLE: 1,
- PHASE_3PHASE: 3,
- }
diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py
index ea780b2d9..cef95a7b6 100644
--- a/netbox/dcim/elevations.py
+++ b/netbox/dcim/elevations.py
@@ -14,10 +14,11 @@ class RackElevationSVG:
Use this class to render a rack elevation as an SVG image.
:param rack: A NetBox Rack instance
+ :param user: User instance. If specified, only devices viewable by this user will be fully displayed.
:param include_images: If true, the SVG document will embed front/rear device face images, where available
:param base_url: Base URL for links within the SVG document. If none, links will be relative.
"""
- def __init__(self, rack, include_images=True, base_url=None):
+ def __init__(self, rack, user=None, include_images=True, base_url=None):
self.rack = rack
self.include_images = include_images
if base_url is not None:
@@ -25,7 +26,14 @@ class RackElevationSVG:
else:
self.base_url = ''
- def _get_device_description(self, device):
+ # Determine the subset of devices within this rack that are viewable by the user, if any
+ permitted_devices = self.rack.devices
+ if user is not None:
+ permitted_devices = permitted_devices.restrict(user, 'view')
+ self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
+
+ @staticmethod
+ def _get_device_description(device):
return '{} ({}) — {} ({}U) {} {}'.format(
device.name,
device.device_role,
@@ -174,10 +182,13 @@ class RackElevationSVG:
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
# Draw the device
- if device and device.face == face:
+ if device and device.face == face and device.pk in self.permitted_device_ids:
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
- elif device and device.device_type.is_full_depth:
+ elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
+ elif device:
+ # Devices which the user does not have permission to view are rendered only as unavailable space
+ drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
else:
# Draw shallow devices, reservations, or empty units
class_ = 'slot'
diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py
index 8c24180bb..457483273 100644
--- a/netbox/dcim/filters.py
+++ b/netbox/dcim/filters.py
@@ -298,6 +298,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
to_field_name='username',
label='User (name)',
)
+ tag = TagFilter()
class Meta:
model = RackReservation
@@ -383,28 +384,28 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil
)
def _console_ports(self, queryset, name, value):
- return queryset.exclude(consoleport_templates__isnull=value)
+ return queryset.exclude(consoleporttemplates__isnull=value)
def _console_server_ports(self, queryset, name, value):
- return queryset.exclude(consoleserverport_templates__isnull=value)
+ return queryset.exclude(consoleserverporttemplates__isnull=value)
def _power_ports(self, queryset, name, value):
- return queryset.exclude(powerport_templates__isnull=value)
+ return queryset.exclude(powerporttemplates__isnull=value)
def _power_outlets(self, queryset, name, value):
- return queryset.exclude(poweroutlet_templates__isnull=value)
+ return queryset.exclude(poweroutlettemplates__isnull=value)
def _interfaces(self, queryset, name, value):
- return queryset.exclude(interface_templates__isnull=value)
+ return queryset.exclude(interfacetemplates__isnull=value)
def _pass_through_ports(self, queryset, name, value):
return queryset.exclude(
- frontport_templates__isnull=value,
- rearport_templates__isnull=value
+ frontporttemplates__isnull=value,
+ rearporttemplates__isnull=value
)
def _device_bays(self, queryset, name, value):
- return queryset.exclude(device_bay_templates__isnull=value)
+ return queryset.exclude(devicebaytemplates__isnull=value)
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
@@ -655,7 +656,7 @@ class DeviceFilterSet(
return queryset.filter(
Q(name__icontains=value) |
Q(serial__icontains=value.strip()) |
- Q(inventory_items__serial__icontains=value.strip()) |
+ Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(comments__icontains=value)
).distinct()
@@ -697,7 +698,7 @@ class DeviceFilterSet(
)
def _device_bays(self, queryset, name, value):
- return queryset.exclude(device_bays__isnull=value)
+ return queryset.exclude(devicebays__isnull=value)
class DeviceComponentFilterSet(django_filters.FilterSet):
@@ -746,6 +747,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
return queryset
return queryset.filter(
Q(name__icontains=value) |
+ Q(label__icontains=value) |
Q(description__icontains=value)
)
@@ -1066,7 +1068,8 @@ class VirtualChassisFilterSet(BaseFilterSet):
if not value.strip():
return queryset
qs_filter = (
- Q(master__name__icontains=value) |
+ Q(name__icontains=value) |
+ Q(members__name__icontains=value) |
Q(domain__icontains=value)
)
return queryset.filter(qs_filter)
@@ -1117,6 +1120,7 @@ class CableFilterSet(BaseFilterSet):
method='filter_device',
field_name='device__tenant__slug'
)
+ tag = TagFilter()
class Meta:
model = Cable
@@ -1265,6 +1269,7 @@ class PowerPanelFilterSet(BaseFilterSet):
lookup_expr='in',
label='Rack group (ID)',
)
+ tag = TagFilter()
class Meta:
model = PowerPanel
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index 4de81ac54..6dd5cb6bf 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -6,28 +6,27 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.utils.safestring import mark_safe
-from mptt.forms import TreeNodeChoiceField
from netaddr import EUI
from netaddr.core import AddrFormatError
from timezone_field import TimeZoneFormField
-from circuits.models import Circuit, Provider
+from circuits.models import Circuit, CircuitTermination, Provider
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
- LocalConfigContextFilterForm, TagField,
+ LocalConfigContextFilterForm,
)
+from extras.models import Tag
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
- APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
- ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
- DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
- NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
- BOOLEAN_WITH_BLANK_CHOICES,
+ APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
+ ColorSelect, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
+ DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK,
+ SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
-from virtualization.models import Cluster, ClusterGroup, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .constants import *
from .models import (
@@ -59,7 +58,6 @@ def get_device_by_name_or_pk(name):
class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
-
field_order = [
'q', 'region', 'site'
]
@@ -70,29 +68,23 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field='slug',
- filter_for={
- 'site': 'region'
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'device_id': 'site',
- }
- )
+ query_params={
+ 'region': '$region'
+ }
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
- label='Device'
+ label='Device',
+ query_params={
+ 'site': '$site'
+ }
)
@@ -127,28 +119,31 @@ class InterfaceCommonForm:
})
-class BulkRenameForm(forms.Form):
+class ComponentForm(BootstrapMixin, forms.Form):
"""
- An extendable form to be used for renaming device components in bulk.
+ Subclass this form when facilitating the creation of one or more device component or component templates based on
+ a name pattern.
"""
- find = forms.CharField()
- replace = forms.CharField()
- use_regex = forms.BooleanField(
+ name_pattern = ExpandableNameField(
+ label='Name'
+ )
+ label_pattern = ExpandableNameField(
+ label='Label',
required=False,
- initial=True,
- label='Use regular expressions'
+ help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
)
def clean(self):
- # Validate regular expression in "find" field
- if self.cleaned_data['use_regex']:
- try:
- re.compile(self.cleaned_data['find'])
- except re.error:
+ # Validate that the number of components being created from both the name_pattern and label_pattern are equal
+ if self.cleaned_data['label_pattern']:
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if name_pattern_count != label_pattern_count:
raise forms.ValidationError({
- 'find': "Invalid regular expression"
- })
+ 'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however '
+ f'{label_pattern_count} labels will be generated. These counts must match.'
+ }, code='label_pattern_mismatch')
#
@@ -178,10 +173,9 @@ class MACAddressField(forms.Field):
#
class RegionForm(BootstrapMixin, forms.ModelForm):
- parent = TreeNodeChoiceField(
+ parent = DynamicModelChoiceField(
queryset=Region.objects.all(),
- required=False,
- widget=StaticSelect2()
+ required=False
)
slug = SlugField()
@@ -218,14 +212,14 @@ class RegionFilterForm(BootstrapMixin, forms.Form):
#
class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
- region = TreeNodeChoiceField(
+ region = DynamicModelChoiceField(
queryset=Region.objects.all(),
- required=False,
- widget=StaticSelect2()
+ required=False
)
slug = SlugField()
comments = CommentField()
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -303,10 +297,9 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
initial='',
widget=StaticSelect2()
)
- region = TreeNodeChoiceField(
+ region = DynamicModelChoiceField(
queryset=Region.objects.all(),
- required=False,
- widget=StaticSelect2()
+ required=False
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@@ -349,10 +342,7 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ required=False
)
tag = TagFilterField(model)
@@ -363,16 +353,14 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackGroupForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
- queryset=Site.objects.all(),
- widget=APISelect(
- filter_for={
- 'parent': 'site_id',
- }
- )
+ queryset=Site.objects.all()
)
parent = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
)
slug = SlugField()
@@ -408,34 +396,24 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region',
- 'parent': 'region',
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'parent': 'site',
- }
- )
+ query_params={
+ 'region': '$region'
+ }
)
parent = DynamicModelMultipleChoiceField(
queryset=RackGroup.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- api_url="/api/dcim/rack-groups/",
- value_field="slug",
- )
+ query_params={
+ 'region': '$region',
+ 'site': '$site',
+ }
)
@@ -470,23 +448,22 @@ class RackRoleCSVForm(CSVModelForm):
class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
site = DynamicModelChoiceField(
- queryset=Site.objects.all(),
- widget=APISelect(
- filter_for={
- 'group': 'site_id',
- }
- )
+ queryset=Site.objects.all()
)
group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
)
role = DynamicModelChoiceField(
queryset=RackRole.objects.all(),
required=False
)
comments = CommentField()
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -573,16 +550,14 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
- widget=APISelect(
- filter_for={
- 'group': 'site_id',
- }
- )
+ required=False
)
group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
@@ -660,34 +635,24 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region'
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'group_id': 'site'
- }
- )
+ query_params={
+ 'region': '$region'
+ }
)
group_id = DynamicModelMultipleChoiceField(
- queryset=RackGroup.objects.prefetch_related(
- 'site'
- ),
+ queryset=RackGroup.objects.all(),
required=False,
label='Rack group',
- widget=APISelectMultiple(
- null_option=True
- )
+ null_option='None',
+ query_params={
+ 'site': '$site'
+ }
)
status = forms.MultipleChoiceField(
choices=RackStatusChoices,
@@ -698,10 +663,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
queryset=RackRole.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- null_option=True,
- )
+ null_option='None'
)
tag = TagFilterField(model)
@@ -716,18 +678,13 @@ class RackElevationFilterForm(RackFilterForm):
queryset=Rack.objects.all(),
label='Rack',
required=False,
- widget=APISelectMultiple(
- display_field='display_name',
- )
+ display_field='display_name',
+ query_params={
+ 'site': '$site',
+ 'group_id': '$group_id',
+ }
)
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Filter the rack field based on the site and group
- self.fields['site'].widget.add_filter_for('id', 'site')
- self.fields['group_id'].widget.add_filter_for('id', 'group_id')
-
#
# Rack reservations
@@ -736,25 +693,22 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
- widget=APISelect(
- filter_for={
- 'rack_group': 'site_id',
- 'rack': 'site_id',
- }
- )
+ required=False
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
- widget=APISelect(
- filter_for={
- 'rack': 'group_id'
- }
- )
+ query_params={
+ 'site_id': '$site'
+ }
)
rack = DynamicModelChoiceField(
- queryset=Rack.objects.all()
+ queryset=Rack.objects.all(),
+ display_field='display_name',
+ query_params={
+ 'site_id': '$site',
+ 'group_id': 'rack',
+ }
)
units = NumericArrayField(
base_field=forms.IntegerField(),
@@ -766,11 +720,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
),
widget=StaticSelect2()
)
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = RackReservation
fields = [
- 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description',
+ 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags',
]
@@ -824,7 +782,7 @@ class RackReservationCSVForm(CSVModelForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
-class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
+class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=RackReservation.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -850,6 +808,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
+ model = RackReservation
field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant']
q = forms.CharField(
required=False,
@@ -858,19 +817,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ required=False
)
group_id = DynamicModelMultipleChoiceField(
queryset=RackGroup.objects.prefetch_related('site'),
required=False,
label='Rack group',
- widget=APISelectMultiple(
- null_option=True,
- )
+ null_option='None'
)
+ tag = TagFilterField(model)
#
@@ -906,7 +861,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
slug_source='model'
)
comments = CommentField()
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -967,10 +923,7 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
manufacturer = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ required=False
)
subdevice_role = forms.MultipleChoiceField(
choices=add_blank_choice(SubdeviceRoleChoices),
@@ -1026,27 +979,23 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Device component templates
#
-class ComponentTemplateCreateForm(BootstrapMixin, forms.Form):
+class ComponentTemplateCreateForm(ComponentForm):
"""
- Base form for the creation of device component templates.
+ Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
"""
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
- required=False,
- widget=APISelect(
- filter_for={
- 'device_type': 'manufacturer_id'
- }
- )
+ required=False
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
- widget=APISelect(
- display_field='model'
- )
+ display_field='model',
+ query_params={
+ 'manufacturer_id': '$manufacturer'
+ }
)
- name_pattern = ExpandableNameField(
- label='Name'
+ description = forms.CharField(
+ required=False
)
@@ -1055,7 +1004,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePortTemplate
fields = [
- 'device_type', 'name', 'type',
+ 'device_type', 'name', 'label', 'type', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1067,6 +1016,7 @@ class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm):
choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2()
)
+ field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -1074,6 +1024,10 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=ConsolePortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
)
+ label = forms.CharField(
+ max_length=64,
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
@@ -1081,7 +1035,7 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
)
class Meta:
- nullable_fields = ('type',)
+ nullable_fields = ('label', 'type', 'description')
class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
@@ -1089,7 +1043,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPortTemplate
fields = [
- 'device_type', 'name', 'type',
+ 'device_type', 'name', 'label', 'type', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1101,6 +1055,7 @@ class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2()
)
+ field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -1108,14 +1063,21 @@ class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=ConsoleServerPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
)
+ label = forms.CharField(
+ max_length=64,
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
widget=StaticSelect2()
)
+ description = forms.CharField(
+ required=False
+ )
class Meta:
- nullable_fields = ('type',)
+ nullable_fields = ('label', 'type', 'description')
class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
@@ -1123,7 +1085,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPortTemplate
fields = [
- 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw',
+ 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1145,6 +1107,10 @@ class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
required=False,
help_text="Allocated power draw (watts)"
)
+ field_order = (
+ 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw',
+ 'description',
+ )
class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -1152,6 +1118,10 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=PowerPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
)
+ label = forms.CharField(
+ max_length=64,
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices),
required=False,
@@ -1167,9 +1137,12 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
required=False,
help_text="Allocated power draw (watts)"
)
+ description = forms.CharField(
+ required=False
+ )
class Meta:
- nullable_fields = ('type', 'maximum_draw', 'allocated_draw')
+ nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
@@ -1177,7 +1150,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutletTemplate
fields = [
- 'device_type', 'name', 'type', 'power_port', 'feed_leg',
+ 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1208,6 +1181,10 @@ class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
required=False,
widget=StaticSelect2()
)
+ field_order = (
+ 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg',
+ 'description',
+ )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1232,6 +1209,10 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
disabled=True,
widget=forms.HiddenInput()
)
+ label = forms.CharField(
+ max_length=64,
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False,
@@ -1246,9 +1227,12 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
required=False,
widget=StaticSelect2()
)
+ description = forms.CharField(
+ required=False
+ )
class Meta:
- nullable_fields = ('type', 'power_port', 'feed_leg')
+ nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1267,7 +1251,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InterfaceTemplate
fields = [
- 'device_type', 'name', 'type', 'mgmt_only',
+ 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1284,6 +1268,7 @@ class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
required=False,
label='Management only'
)
+ field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description')
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -1291,6 +1276,10 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=InterfaceTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
)
+ label = forms.CharField(
+ max_length=64,
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(InterfaceTypeChoices),
required=False,
@@ -1301,9 +1290,12 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
widget=BulkEditNullBooleanSelect,
label='Management only'
)
+ description = forms.CharField(
+ required=False
+ )
class Meta:
- nullable_fields = []
+ nullable_fields = ('label', 'description')
class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
@@ -1311,7 +1303,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = FrontPortTemplate
fields = [
- 'device_type', 'name', 'type', 'rear_port', 'rear_port_position',
+ 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1339,6 +1331,9 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
label='Rear ports',
help_text='Select one rear port assignment for each front port being created.',
)
+ field_order = (
+ 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'rear_port_set', 'description',
+ )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1350,7 +1345,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
# Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
occupied_port_positions = [
(front_port.rear_port_id, front_port.rear_port_position)
- for front_port in device_type.frontport_templates.all()
+ for front_port in device_type.frontporttemplates.all()
]
# Populate rear port choices
@@ -1391,14 +1386,21 @@ class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=FrontPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
)
+ label = forms.CharField(
+ max_length=64,
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(PortTypeChoices),
required=False,
widget=StaticSelect2()
)
+ description = forms.CharField(
+ required=False
+ )
class Meta:
- nullable_fields = ()
+ nullable_fields = ('description',)
class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
@@ -1406,7 +1408,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = RearPortTemplate
fields = [
- 'device_type', 'name', 'type', 'positions',
+ 'device_type', 'name', 'label', 'type', 'positions', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1425,6 +1427,7 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
initial=1,
help_text='The number of front ports which may be mapped to each rear port'
)
+ field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'positions', 'description')
class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -1432,14 +1435,21 @@ class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=RearPortTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
)
+ label = forms.CharField(
+ max_length=64,
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(PortTypeChoices),
required=False,
widget=StaticSelect2()
)
+ description = forms.CharField(
+ required=False
+ )
class Meta:
- nullable_fields = ()
+ nullable_fields = ('description',)
class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
@@ -1447,7 +1457,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = DeviceBayTemplate
fields = [
- 'device_type', 'name',
+ 'device_type', 'name', 'label', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1455,18 +1465,24 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
- pass
+ field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
-# TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet
-# class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
-# pk = forms.ModelMultipleChoiceField(
-# queryset=FrontPortTemplate.objects.all(),
-# widget=forms.MultipleHiddenInput()
-# )
-#
-# class Meta:
-# nullable_fields = ()
+class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=DeviceBayTemplate.objects.all(),
+ widget=forms.MultipleHiddenInput()
+ )
+ label = forms.CharField(
+ max_length=64,
+ required=False
+ )
+ description = forms.CharField(
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ('label', 'description')
#
@@ -1501,7 +1517,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsolePortTemplate
fields = [
- 'device_type', 'name', 'type',
+ 'device_type', 'name', 'label', 'type',
]
@@ -1510,7 +1526,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsoleServerPortTemplate
fields = [
- 'device_type', 'name', 'type',
+ 'device_type', 'name', 'label', 'type',
]
@@ -1519,7 +1535,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = PowerPortTemplate
fields = [
- 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw',
+ 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
]
@@ -1533,7 +1549,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = PowerOutletTemplate
fields = [
- 'device_type', 'name', 'type', 'power_port', 'feed_leg',
+ 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
]
@@ -1545,7 +1561,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = InterfaceTemplate
fields = [
- 'device_type', 'name', 'type', 'mgmt_only',
+ 'device_type', 'name', 'label', 'type', 'mgmt_only',
]
@@ -1654,20 +1670,23 @@ class PlatformCSVForm(CSVModelForm):
#
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
+ region = DynamicModelChoiceField(
+ queryset=Region.objects.all(),
+ required=False
+ )
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- widget=APISelect(
- filter_for={
- 'rack': 'site_id'
- }
- )
+ query_params={
+ 'region_id': '$region'
+ }
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
- widget=APISelect(
- display_field='display_name'
- )
+ display_field='display_name',
+ query_params={
+ 'site_id': '$site'
+ }
)
position = forms.TypedChoiceField(
required=False,
@@ -1675,24 +1694,22 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/elevation/',
- disabled_indicator='device'
+ attrs={
+ 'disabled-indicator': 'device',
+ 'data-query-param-face': "[\"$face\"]",
+ }
)
)
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
- required=False,
- widget=APISelect(
- filter_for={
- 'device_type': 'manufacturer_id',
- 'platform': 'manufacturer_id'
- }
- )
+ required=False
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
- widget=APISelect(
- display_field='model'
- )
+ display_field='model',
+ query_params={
+ 'manufacturer_id': '$manufacturer'
+ }
)
device_role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all()
@@ -1700,34 +1717,31 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
platform = DynamicModelChoiceField(
queryset=Platform.objects.all(),
required=False,
- widget=APISelect(
- additional_query_params={
- "manufacturer_id": "null"
- }
- )
+ query_params={
+ 'manufacturer_id': ['$manufacturer', 'null']
+ }
)
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
- widget=APISelect(
- filter_for={
- 'cluster': 'group_id'
- },
- attrs={
- 'nullable': 'true'
- }
- )
+ null_option='None'
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'group_id': '$cluster_group'
+ }
)
comments = CommentField()
- tags = TagField(required=False)
local_context_data = JSONField(
required=False,
label=''
)
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = Device
@@ -1743,11 +1757,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
"config context",
}
widgets = {
- 'face': StaticSelect2(
- filter_for={
- 'position': 'face'
- }
- ),
'status': StaticSelect2(),
'primary_ip4': StaticSelect2(),
'primary_ip6': StaticSelect2(),
@@ -1784,27 +1793,31 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
ip_choices = [(None, '---------')]
# Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
- interface_ids = self.instance.vc_interfaces.values('pk')
+ interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True)
# Collect interface IPs
- interface_ips = IPAddress.objects.prefetch_related('interface').filter(
- address__family=family, interface_id__in=interface_ids
- )
+ interface_ips = IPAddress.objects.filter(
+ address__family=family,
+ assigned_object_type=ContentType.objects.get_for_model(Interface),
+ assigned_object_id__in=interface_ids
+ ).prefetch_related('assigned_object')
if interface_ips:
- ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
+ ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list))
# Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
- address__family=family, nat_inside__interface__in=interface_ids
- )
+ address__family=family,
+ nat_inside__assigned_object_type=ContentType.objects.get_for_model(Interface),
+ nat_inside__assigned_object_id__in=interface_ids
+ ).prefetch_related('assigned_object')
if nat_ips:
- ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
+ ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in nat_ips]
ip_choices.append(('NAT IPs', ip_list))
self.fields['primary_ip{}'.format(family)].choices = ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another.
- self.fields['position'].widget.add_additional_query_param('exclude', self.instance.pk)
+ self.fields['position'].widget.add_query_param('exclude', self.instance.pk)
# Limit platform by manufacturer
self.fields['platform'].queryset = Platform.objects.filter(
@@ -1994,12 +2007,17 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
)
+ manufacturer = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False
+ )
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False,
- widget=APISelect(
- display_field="model",
- )
+ display_field='model',
+ query_params={
+ 'manufacturer_id': '$manufacturer'
+ }
)
device_role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all(),
@@ -2043,78 +2061,59 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region'
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'rack_group_id': 'site',
- 'rack_id': 'site',
- }
- )
+ query_params={
+ 'region': '$region'
+ }
)
rack_group_id = DynamicModelMultipleChoiceField(
queryset=RackGroup.objects.all(),
required=False,
label='Rack group',
- widget=APISelectMultiple(
- filter_for={
- 'rack_id': 'group_id',
- }
- )
+ query_params={
+ 'site': '$site'
+ }
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label='Rack',
- widget=APISelectMultiple(
- null_option=True,
- )
+ null_option='None',
+ query_params={
+ 'site': '$site',
+ 'group_id': '$rack_group_id',
+ }
)
role = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ required=False
)
- manufacturer_id = DynamicModelMultipleChoiceField(
+ manufacturer = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
+ to_field_name='slug',
required=False,
- label='Manufacturer',
- widget=APISelectMultiple(
- filter_for={
- 'device_type_id': 'manufacturer_id',
- }
- )
+ label='Manufacturer'
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label='Model',
- widget=APISelectMultiple(
- display_field="model",
- )
+ display_field='model',
+ query_params={
+ 'manufacturer': '$manufacturer'
+ }
)
platform = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- null_option=True,
- )
+ null_option='None'
)
status = forms.MultipleChoiceField(
choices=DeviceStatusChoices,
@@ -2188,31 +2187,37 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
# Device components
#
-class ComponentCreateForm(BootstrapMixin, forms.Form):
+class ComponentCreateForm(ComponentForm):
"""
- Base form for the creation of device components.
+ Base form for the creation of device components (models subclassed from ComponentModel).
"""
device = DynamicModelChoiceField(
- queryset=Device.objects.all()
+ queryset=Device.objects.all(),
+ display_field='display_name'
)
- name_pattern = ExpandableNameField(
- label='Name'
+ description = forms.CharField(
+ max_length=100,
+ required=False
+ )
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
)
-class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
+class DeviceBulkAddComponentForm(ComponentForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
)
- name_pattern = ExpandableNameField(
- label='Name'
+ description = forms.CharField(
+ max_length=100,
+ required=False
+ )
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
)
-
- def clean_tags(self):
- # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we
- # must first convert the list of tags to a string.
- return ','.join(self.cleaned_data.get('tags'))
#
@@ -2231,14 +2236,15 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
class ConsolePortForm(BootstrapMixin, forms.ModelForm):
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
class Meta:
model = ConsolePort
fields = [
- 'device', 'name', 'type', 'description', 'tags',
+ 'device', 'name', 'label', 'type', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -2251,24 +2257,18 @@ class ConsolePortCreateForm(ComponentCreateForm):
required=False,
widget=StaticSelect2()
)
- description = forms.CharField(
- max_length=100,
- required=False
- )
- tags = TagField(
- required=False
- )
+ field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'description', 'tags')
class ConsolePortBulkCreateForm(
- form_from_model(ConsolePort, ['type', 'description', 'tags']),
+ form_from_model(ConsolePort, ['type']),
DeviceBulkAddComponentForm
):
- pass
+ field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags')
class ConsolePortBulkEditForm(
- form_from_model(ConsolePort, ['type', 'description']),
+ form_from_model(ConsolePort, ['label', 'type', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
BulkEditForm
@@ -2279,9 +2279,7 @@ class ConsolePortBulkEditForm(
)
class Meta:
- nullable_fields = (
- 'description',
- )
+ nullable_fields = ('label', 'description')
class ConsolePortCSVForm(CSVModelForm):
@@ -2311,7 +2309,8 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -2331,24 +2330,18 @@ class ConsoleServerPortCreateForm(ComponentCreateForm):
required=False,
widget=StaticSelect2()
)
- description = forms.CharField(
- max_length=100,
- required=False
- )
- tags = TagField(
- required=False
- )
+ field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'description', 'tags')
class ConsoleServerPortBulkCreateForm(
- form_from_model(ConsoleServerPort, ['type', 'description', 'tags']),
+ form_from_model(ConsoleServerPort, ['type']),
DeviceBulkAddComponentForm
):
- pass
+ field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags')
class ConsoleServerPortBulkEditForm(
- form_from_model(ConsoleServerPort, ['type', 'description']),
+ form_from_model(ConsoleServerPort, ['label', 'type', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
BulkEditForm
@@ -2359,23 +2352,7 @@ class ConsoleServerPortBulkEditForm(
)
class Meta:
- nullable_fields = [
- 'description',
- ]
-
-
-class ConsoleServerPortBulkRenameForm(BulkRenameForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=ConsoleServerPort.objects.all(),
- widget=forms.MultipleHiddenInput()
- )
-
-
-class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=ConsoleServerPort.objects.all(),
- widget=forms.MultipleHiddenInput()
- )
+ nullable_fields = ('label', 'description')
class ConsoleServerPortCSVForm(CSVModelForm):
@@ -2405,7 +2382,8 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
class PowerPortForm(BootstrapMixin, forms.ModelForm):
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -2435,24 +2413,20 @@ class PowerPortCreateForm(ComponentCreateForm):
required=False,
help_text="Allocated draw in watts"
)
- description = forms.CharField(
- max_length=100,
- required=False
- )
- tags = TagField(
- required=False
+ field_order = (
+ 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags',
)
class PowerPortBulkCreateForm(
- form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']),
+ form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw']),
DeviceBulkAddComponentForm
):
- pass
+ field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
class PowerPortBulkEditForm(
- form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description']),
+ form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
BulkEditForm
@@ -2463,9 +2437,7 @@ class PowerPortBulkEditForm(
)
class Meta:
- nullable_fields = (
- 'description',
- )
+ nullable_fields = ('label', 'description')
class PowerPortCSVForm(CSVModelForm):
@@ -2499,7 +2471,8 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
queryset=PowerPort.objects.all(),
required=False
)
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -2536,13 +2509,7 @@ class PowerOutletCreateForm(ComponentCreateForm):
choices=add_blank_choice(PowerOutletFeedLegChoices),
required=False
)
- description = forms.CharField(
- max_length=100,
- required=False
- )
- tags = TagField(
- required=False
- )
+ field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'description', 'tags')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -2555,14 +2522,14 @@ class PowerOutletCreateForm(ComponentCreateForm):
class PowerOutletBulkCreateForm(
- form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']),
+ form_from_model(PowerOutlet, ['type', 'feed_leg']),
DeviceBulkAddComponentForm
):
- pass
+ field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags')
class PowerOutletBulkEditForm(
- form_from_model(PowerOutlet, ['type', 'feed_leg', 'power_port', 'description']),
+ form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
BulkEditForm
@@ -2579,9 +2546,7 @@ class PowerOutletBulkEditForm(
)
class Meta:
- nullable_fields = [
- 'type', 'feed_leg', 'power_port', 'description',
- ]
+ nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -2595,20 +2560,6 @@ class PowerOutletBulkEditForm(
self.fields['power_port'].widget.attrs['disabled'] = True
-class PowerOutletBulkRenameForm(BulkRenameForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=PowerOutlet.objects.all(),
- widget=forms.MultipleHiddenInput
- )
-
-
-class PowerOutletBulkDisconnectForm(ConfirmationForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=PowerOutlet.objects.all(),
- widget=forms.MultipleHiddenInput
- )
-
-
class PowerOutletCSVForm(CSVModelForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
@@ -2683,34 +2634,31 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
queryset=VLAN.objects.all(),
required=False,
label='Untagged VLAN',
- widget=APISelect(
- display_field='display_name',
- full=True,
- additional_query_params={
- 'site_id': 'null',
- },
- )
+ display_field='display_name',
+ brief_mode=False,
+ query_params={
+ 'site_id': 'null',
+ }
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
label='Tagged VLANs',
- widget=APISelectMultiple(
- display_field='display_name',
- full=True,
- additional_query_params={
- 'site_id': 'null',
- },
- )
+ display_field='display_name',
+ brief_mode=False,
+ query_params={
+ 'site_id': 'null',
+ }
)
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Interface
fields = [
- 'device', 'name', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
+ 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
]
widgets = {
@@ -2734,15 +2682,15 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
else:
device = self.instance.device
- # Limit LAG choices to interfaces belonging to this device (or VC master)
- self.fields['lag'].queryset = Interface.objects.filter(
- device__in=[device, device.get_vc_master()],
- type=InterfaceTypeChoices.TYPE_LAG
- )
+ # Limit LAG choices to interfaces belonging to this device or a peer VC member
+ device_query = Q(device=device)
+ if device.virtual_chassis:
+ device_query |= Q(device__virtual_chassis=device.virtual_chassis)
+ self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG)
# Add current site to VLANs query params
- self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
- self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
+ self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
+ self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
@@ -2775,67 +2723,62 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
label='Management only',
help_text='This interface is used only for out-of-band management'
)
- description = forms.CharField(
- max_length=100,
- required=False
- )
mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices),
required=False,
widget=StaticSelect2(),
)
- tags = TagField(
- required=False
- )
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
- widget=APISelect(
- display_field='display_name',
- full=True,
- additional_query_params={
- 'site_id': 'null',
- },
- )
+ display_field='display_name',
+ brief_mode=False,
+ query_params={
+ 'site_id': 'null',
+ }
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
- widget=APISelectMultiple(
- display_field='display_name',
- full=True,
- additional_query_params={
- 'site_id': 'null',
- },
- )
+ display_field='display_name',
+ brief_mode=False,
+ query_params={
+ 'site_id': 'null',
+ }
+ )
+ field_order = (
+ 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'description',
+ 'mgmt_only', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- # Limit LAG choices to interfaces which belong to the parent device (or VC master)
+ # Limit LAG choices to interfaces belonging to this device or a peer VC member
device = Device.objects.get(
pk=self.initial.get('device') or self.data.get('device')
)
- self.fields['lag'].queryset = Interface.objects.filter(
- device__in=[device, device.get_vc_master()],
- type=InterfaceTypeChoices.TYPE_LAG
- )
+ device_query = Q(device=device)
+ if device.virtual_chassis:
+ device_query |= Q(device__virtual_chassis=device.virtual_chassis)
+ self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG)
# Add current site to VLANs query params
- self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
- self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
+ self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
+ self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
class InterfaceBulkCreateForm(
- form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']),
+ form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only']),
DeviceBulkAddComponentForm
):
- pass
+ field_order = ('name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags')
class InterfaceBulkEditForm(
- form_from_model(Interface, ['type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode']),
+ form_from_model(Interface, [
+ 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode'
+ ]),
BootstrapMixin,
AddRemoveTagsForm,
BulkEditForm
@@ -2853,30 +2796,26 @@ class InterfaceBulkEditForm(
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
- widget=APISelect(
- display_field='display_name',
- full=True,
- additional_query_params={
- 'site_id': 'null',
- },
- )
+ display_field='display_name',
+ brief_mode=False,
+ query_params={
+ 'site_id': 'null',
+ }
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
- widget=APISelectMultiple(
- display_field='display_name',
- full=True,
- additional_query_params={
- 'site_id': 'null',
- },
- )
+ display_field='display_name',
+ brief_mode=False,
+ query_params={
+ 'site_id': 'null',
+ }
)
class Meta:
- nullable_fields = [
- 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
- ]
+ nullable_fields = (
+ 'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans'
+ )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -2890,8 +2829,8 @@ class InterfaceBulkEditForm(
)
# Add current site to VLANs query params
- self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
- self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
+ self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
+ self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
else:
self.fields['lag'].choices = ()
self.fields['lag'].widget.attrs['disabled'] = True
@@ -2909,29 +2848,9 @@ class InterfaceBulkEditForm(
self.cleaned_data['tagged_vlans'] = []
-class InterfaceBulkRenameForm(BulkRenameForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=Interface.objects.all(),
- widget=forms.MultipleHiddenInput()
- )
-
-
-class InterfaceBulkDisconnectForm(ConfirmationForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=Interface.objects.all(),
- widget=forms.MultipleHiddenInput()
- )
-
-
class InterfaceCSVForm(CSVModelForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
- required=False,
- to_field_name='name'
- )
- virtual_machine = CSVModelChoiceField(
- queryset=VirtualMachine.objects.all(),
- required=False,
to_field_name='name'
)
lag = CSVModelChoiceField(
@@ -2958,13 +2877,12 @@ class InterfaceCSVForm(CSVModelForm):
super().__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device (or VC master)
+ device = None
if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
- device = None
- else:
- device = self.instance.device
+ pass
if device:
self.fields['lag'].queryset = Interface.objects.filter(
@@ -2996,14 +2914,15 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
class FrontPortForm(BootstrapMixin, forms.ModelForm):
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
class Meta:
model = FrontPort
fields = [
- 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'tags',
+ 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -3032,9 +2951,7 @@ class FrontPortCreateForm(ComponentCreateForm):
label='Rear ports',
help_text='Select one rear port assignment for each front port being created.',
)
- description = forms.CharField(
- required=False
- )
+ field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'rear_port_set', 'description', 'tags')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -3084,14 +3001,14 @@ class FrontPortCreateForm(ComponentCreateForm):
# class FrontPortBulkCreateForm(
-# form_from_model(FrontPort, ['type', 'description', 'tags']),
+# form_from_model(FrontPort, ['label', 'type', 'description', 'tags']),
# DeviceBulkAddComponentForm
# ):
# pass
class FrontPortBulkEditForm(
- form_from_model(FrontPort, ['type', 'description']),
+ form_from_model(FrontPort, ['label', 'type', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
BulkEditForm
@@ -3102,23 +3019,7 @@ class FrontPortBulkEditForm(
)
class Meta:
- nullable_fields = [
- 'description',
- ]
-
-
-class FrontPortBulkRenameForm(BulkRenameForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=FrontPort.objects.all(),
- widget=forms.MultipleHiddenInput
- )
-
-
-class FrontPortBulkDisconnectForm(ConfirmationForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=FrontPort.objects.all(),
- widget=forms.MultipleHiddenInput
- )
+ nullable_fields = ('label', 'description')
class FrontPortCSVForm(CSVModelForm):
@@ -3181,14 +3082,15 @@ class RearPortFilterForm(DeviceComponentFilterForm):
class RearPortForm(BootstrapMixin, forms.ModelForm):
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
class Meta:
model = RearPort
fields = [
- 'device', 'name', 'type', 'positions', 'description', 'tags',
+ 'device', 'name', 'label', 'type', 'positions', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -3207,20 +3109,18 @@ class RearPortCreateForm(ComponentCreateForm):
initial=1,
help_text='The number of front ports which may be mapped to each rear port'
)
- description = forms.CharField(
- required=False
- )
+ field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'positions', 'description', 'tags')
class RearPortBulkCreateForm(
- form_from_model(RearPort, ['type', 'positions', 'description', 'tags']),
+ form_from_model(RearPort, ['type', 'positions']),
DeviceBulkAddComponentForm
):
- pass
+ field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'description', 'tags')
class RearPortBulkEditForm(
- form_from_model(RearPort, ['type', 'description']),
+ form_from_model(RearPort, ['label', 'type', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
BulkEditForm
@@ -3231,23 +3131,7 @@ class RearPortBulkEditForm(
)
class Meta:
- nullable_fields = [
- 'description',
- ]
-
-
-class RearPortBulkRenameForm(BulkRenameForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=RearPort.objects.all(),
- widget=forms.MultipleHiddenInput
- )
-
-
-class RearPortBulkDisconnectForm(ConfirmationForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=RearPort.objects.all(),
- widget=forms.MultipleHiddenInput
- )
+ nullable_fields = ('label', 'description')
class RearPortCSVForm(CSVModelForm):
@@ -3278,14 +3162,15 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
class DeviceBayForm(BootstrapMixin, forms.ModelForm):
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
class Meta:
model = DeviceBay
fields = [
- 'device', 'name', 'description', 'tags',
+ 'device', 'name', 'label', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -3293,9 +3178,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
class DeviceBayCreateForm(ComponentCreateForm):
- tags = TagField(
- required=False
- )
+ field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
@@ -3319,17 +3202,12 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
).exclude(pk=device_bay.device.pk)
-class DeviceBayBulkCreateForm(
- form_from_model(DeviceBay, ['description', 'tags']),
- DeviceBulkAddComponentForm
-):
- tags = TagField(
- required=False
- )
+class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
+ field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
class DeviceBayBulkEditForm(
- form_from_model(DeviceBay, ['description']),
+ form_from_model(DeviceBay, ['label', 'description']),
BootstrapMixin,
AddRemoveTagsForm,
BulkEditForm
@@ -3340,16 +3218,7 @@ class DeviceBayBulkEditForm(
)
class Meta:
- nullable_fields = (
- 'description',
- )
-
-
-class DeviceBayBulkRenameForm(BulkRenameForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=DeviceBay.objects.all(),
- widget=forms.MultipleHiddenInput()
- )
+ nullable_fields = ('label', 'description')
class DeviceBayCSVForm(CSVModelForm):
@@ -3398,6 +3267,122 @@ class DeviceBayCSVForm(CSVModelForm):
self.fields['installed_device'].queryset = Interface.objects.none()
+#
+# Inventory items
+#
+
+class InventoryItemForm(BootstrapMixin, forms.ModelForm):
+ device = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ display_field='display_name'
+ )
+ manufacturer = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False
+ )
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+
+ class Meta:
+ model = InventoryItem
+ fields = [
+ 'name', 'label', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
+ ]
+
+
+class InventoryItemCreateForm(ComponentCreateForm):
+ manufacturer = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False
+ )
+ part_id = forms.CharField(
+ max_length=50,
+ required=False,
+ label='Part ID'
+ )
+ serial = forms.CharField(
+ max_length=50,
+ required=False,
+ )
+ asset_tag = forms.CharField(
+ max_length=50,
+ required=False,
+ )
+ field_order = (
+ 'device', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+ 'tags',
+ )
+
+
+class InventoryItemCSVForm(CSVModelForm):
+ device = CSVModelChoiceField(
+ queryset=Device.objects.all(),
+ to_field_name='name'
+ )
+ manufacturer = CSVModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name='name',
+ required=False
+ )
+
+ class Meta:
+ model = InventoryItem
+ fields = InventoryItem.csv_headers
+
+
+class InventoryItemBulkCreateForm(
+ form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
+ DeviceBulkAddComponentForm
+):
+ field_order = (
+ 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
+ 'tags',
+ )
+
+
+class InventoryItemBulkEditForm(
+ form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']),
+ BootstrapMixin,
+ AddRemoveTagsForm,
+ BulkEditForm
+):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=InventoryItem.objects.all(),
+ widget=forms.MultipleHiddenInput()
+ )
+ manufacturer = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False
+ )
+
+ class Meta:
+ nullable_fields = ('label', 'manufacturer', 'part_id', 'description')
+
+
+class InventoryItemFilterForm(DeviceComponentFilterForm):
+ model = InventoryItem
+ manufacturer = DynamicModelMultipleChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name='slug',
+ required=False
+ )
+ serial = forms.CharField(
+ required=False
+ )
+ asset_tag = forms.CharField(
+ required=False
+ )
+ discovered = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect2(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ tag = TagFilterField(model)
+
+
#
# Cables
#
@@ -3409,37 +3394,27 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
- required=False,
- widget=APISelect(
- filter_for={
- 'termination_b_rack': 'site_id',
- 'termination_b_device': 'site_id',
- }
- )
+ required=False
)
termination_b_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
- widget=APISelect(
- filter_for={
- 'termination_b_device': 'rack_id',
- },
- attrs={
- 'nullable': 'true',
- }
- )
+ display_field='display_name',
+ null_option='None',
+ query_params={
+ 'site_id': '$termination_b_site'
+ }
)
termination_b_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
label='Device',
required=False,
- widget=APISelect(
- display_field='display_name',
- filter_for={
- 'termination_b_id': 'device_id',
- }
- )
+ display_field='display_name',
+ query_params={
+ 'site_id': '$termination_b_site',
+ 'rack_id': '$termination_b_rack',
+ }
)
class Meta:
@@ -3454,77 +3429,86 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
'length_unit': StaticSelect2,
}
+ def clean_termination_b_id(self):
+ # Return the PK rather than the object
+ return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
+
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=ConsolePort.objects.all(),
label='Name',
- widget=APISelect(
- api_url='/api/dcim/console-ports/',
- disabled_indicator='cable',
- )
+ disabled_indicator='cable',
+ query_params={
+ 'device_id': '$termination_b_device'
+ }
)
class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm):
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=ConsoleServerPort.objects.all(),
label='Name',
- widget=APISelect(
- api_url='/api/dcim/console-server-ports/',
- disabled_indicator='cable',
- )
+ disabled_indicator='cable',
+ query_params={
+ 'device_id': '$termination_b_device'
+ }
)
class ConnectCableToPowerPortForm(ConnectCableToDeviceForm):
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=PowerPort.objects.all(),
label='Name',
- widget=APISelect(
- api_url='/api/dcim/power-ports/',
- disabled_indicator='cable',
- )
+ disabled_indicator='cable',
+ query_params={
+ 'device_id': '$termination_b_device'
+ }
)
class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm):
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=PowerOutlet.objects.all(),
label='Name',
- widget=APISelect(
- api_url='/api/dcim/power-outlets/',
- disabled_indicator='cable',
- )
+ disabled_indicator='cable',
+ query_params={
+ 'device_id': '$termination_b_device'
+ }
)
class ConnectCableToInterfaceForm(ConnectCableToDeviceForm):
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=Interface.objects.all(),
label='Name',
- widget=APISelect(
- api_url='/api/dcim/interfaces/',
- disabled_indicator='cable',
- additional_query_params={
- 'kind': 'physical',
- }
- )
+ disabled_indicator='cable',
+ query_params={
+ 'device_id': '$termination_b_device',
+ 'kind': 'physical',
+ }
)
class ConnectCableToFrontPortForm(ConnectCableToDeviceForm):
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=FrontPort.objects.all(),
label='Name',
- widget=APISelect(
- api_url='/api/dcim/front-ports/',
- disabled_indicator='cable',
- )
+ disabled_indicator='cable',
+ query_params={
+ 'device_id': '$termination_b_device'
+ }
)
class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=RearPort.objects.all(),
label='Name',
- widget=APISelect(
- api_url='/api/dcim/rear-ports/',
- disabled_indicator='cable',
- )
+ disabled_indicator='cable',
+ query_params={
+ 'device_id': '$termination_b_device'
+ }
)
@@ -3532,41 +3516,30 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
termination_b_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
- required=False,
- widget=APISelect(
- filter_for={
- 'termination_b_circuit': 'provider_id',
- }
- )
+ required=False
)
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
- required=False,
- widget=APISelect(
- filter_for={
- 'termination_b_circuit': 'site_id',
- }
- )
+ required=False
)
termination_b_circuit = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
label='Circuit',
- widget=APISelect(
- display_field='cid',
- filter_for={
- 'termination_b_id': 'circuit_id',
- }
- )
+ display_field='cid',
+ query_params={
+ 'provider_id': '$termination_b_provider',
+ 'site_id': '$termination_b_site',
+ }
)
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=CircuitTermination.objects.all(),
label='Side',
- widget=APISelect(
- api_url='/api/circuits/circuit-terminations/',
- disabled_indicator='cable',
- display_field='term_side',
- full=True
- )
+ display_field='term_side',
+ disabled_indicator='cable',
+ query_params={
+ 'circuit_id': '$termination_b_circuit'
+ }
)
class Meta:
@@ -3576,46 +3549,43 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
'status', 'label', 'color', 'length', 'length_unit',
]
+ def clean_termination_b_id(self):
+ # Return the PK rather than the object
+ return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
+
class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
termination_b_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
- widget=APISelect(
- display_field='cid',
- filter_for={
- 'termination_b_rackgroup': 'site_id',
- 'termination_b_powerpanel': 'site_id',
- }
- )
+ display_field='cid'
)
termination_b_rackgroup = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
label='Rack Group',
required=False,
- widget=APISelect(
- display_field='cid',
- filter_for={
- 'termination_b_powerpanel': 'rackgroup_id',
- }
- )
+ display_field='cid',
+ query_params={
+ 'site_id': '$termination_b_site'
+ }
)
termination_b_powerpanel = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
label='Power Panel',
required=False,
- widget=APISelect(
- filter_for={
- 'termination_b_id': 'power_panel_id',
- }
- )
+ query_params={
+ 'site_id': '$termination_b_site',
+ 'rack_group_id': '$termination_b_rackgroup',
+ }
)
- termination_b_id = forms.IntegerField(
+ termination_b_id = DynamicModelChoiceField(
+ queryset=PowerFeed.objects.all(),
label='Name',
- widget=APISelect(
- api_url='/api/dcim/power-feeds/',
- )
+ disabled_indicator='cable',
+ query_params={
+ 'power_panel_id': '$termination_b_powerpanel'
+ }
)
class Meta:
@@ -3625,13 +3595,21 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
'color', 'length', 'length_unit',
]
+ def clean_termination_b_id(self):
+ # Return the PK rather than the object
+ return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
+
class CableForm(BootstrapMixin, forms.ModelForm):
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
class Meta:
model = Cable
fields = [
- 'type', 'status', 'label', 'color', 'length', 'length_unit',
+ 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
]
widgets = {
'status': StaticSelect2,
@@ -3764,7 +3742,7 @@ class CableCSVForm(CSVModelForm):
return length_unit if length_unit is not None else ''
-class CableBulkEditForm(BootstrapMixin, BulkEditForm):
+class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Cable.objects.all(),
widget=forms.MultipleHiddenInput
@@ -3826,36 +3804,21 @@ class CableFilterForm(BootstrapMixin, forms.Form):
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'rack_id': 'site',
- 'device_id': 'site',
- }
- )
+ required=False
)
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field='slug',
- filter_for={
- 'device_id': 'tenant',
- }
- )
+ required=False
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label='Rack',
- widget=APISelectMultiple(
- null_option=True,
- filter_for={
- 'device_id': 'rack_id',
- }
- )
+ null_option='None',
+ query_params={
+ 'site': '$site'
+ }
)
type = forms.MultipleChoiceField(
choices=add_blank_choice(CableTypeChoices),
@@ -3875,8 +3838,14 @@ class CableFilterForm(BootstrapMixin, forms.Form):
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
- label='Device'
+ label='Device',
+ query_params={
+ 'site': '$site',
+ 'tenant': '$tenant',
+ 'rack_id': '$rack_id',
+ }
)
+ tag = TagFilterField(model)
#
@@ -3887,18 +3856,15 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'device_id': 'site',
- }
- )
+ required=False
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
- label='Device'
+ label='Device',
+ query_params={
+ 'site': '$site'
+ }
)
@@ -3906,18 +3872,15 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'device_id': 'site',
- }
- )
+ required=False
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
- label='Device'
+ label='Device',
+ query_params={
+ 'site': '$site'
+ }
)
@@ -3925,169 +3888,18 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'device_id': 'site',
- }
- )
+ required=False
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
- label='Device'
+ label='Device',
+ query_params={
+ 'site': '$site'
+ }
)
-#
-# Inventory items
-#
-
-class InventoryItemForm(BootstrapMixin, forms.ModelForm):
- device = DynamicModelChoiceField(
- queryset=Device.objects.prefetch_related('device_type__manufacturer')
- )
- manufacturer = DynamicModelChoiceField(
- queryset=Manufacturer.objects.all(),
- required=False
- )
- tags = TagField(
- required=False
- )
-
- class Meta:
- model = InventoryItem
- fields = [
- 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
- ]
-
-
-class InventoryItemCreateForm(BootstrapMixin, forms.Form):
- device = DynamicModelChoiceField(
- queryset=Device.objects.prefetch_related('device_type__manufacturer')
- )
- name_pattern = ExpandableNameField(
- label='Name'
- )
- manufacturer = DynamicModelChoiceField(
- queryset=Manufacturer.objects.all(),
- required=False
- )
- part_id = forms.CharField(
- max_length=50,
- required=False,
- label='Part ID'
- )
- serial = forms.CharField(
- max_length=50,
- required=False,
- )
- asset_tag = forms.CharField(
- max_length=50,
- required=False,
- )
- description = forms.CharField(
- max_length=100,
- required=False
- )
-
-
-class InventoryItemCSVForm(CSVModelForm):
- device = CSVModelChoiceField(
- queryset=Device.objects.all(),
- to_field_name='name'
- )
- manufacturer = CSVModelChoiceField(
- queryset=Manufacturer.objects.all(),
- to_field_name='name',
- required=False
- )
-
- class Meta:
- model = InventoryItem
- fields = InventoryItem.csv_headers
-
-
-class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
- pk = forms.ModelMultipleChoiceField(
- queryset=InventoryItem.objects.all(),
- widget=forms.MultipleHiddenInput()
- )
- device = DynamicModelChoiceField(
- queryset=Device.objects.all(),
- required=False
- )
- manufacturer = DynamicModelChoiceField(
- queryset=Manufacturer.objects.all(),
- required=False
- )
- part_id = forms.CharField(
- max_length=50,
- required=False,
- label='Part ID'
- )
- description = forms.CharField(
- max_length=100,
- required=False
- )
-
- class Meta:
- nullable_fields = [
- 'manufacturer', 'part_id', 'description',
- ]
-
-
-class InventoryItemFilterForm(BootstrapMixin, forms.Form):
- model = InventoryItem
- q = forms.CharField(
- required=False,
- label='Search'
- )
- region = DynamicModelMultipleChoiceField(
- queryset=Region.objects.all(),
- to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region'
- }
- )
- )
- site = DynamicModelMultipleChoiceField(
- queryset=Site.objects.all(),
- to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'device_id': 'site'
- }
- )
- )
- device_id = DynamicModelMultipleChoiceField(
- queryset=Device.objects.all(),
- required=False,
- label='Device'
- )
- manufacturer = DynamicModelMultipleChoiceField(
- queryset=Manufacturer.objects.all(),
- to_field_name='slug',
- required=False,
- widget=APISelect(
- value_field="slug",
- )
- )
- discovered = forms.NullBooleanField(
- required=False,
- widget=StaticSelect2(
- choices=BOOLEAN_WITH_BLANK_CHOICES
- )
- )
- tag = TagFilterField(model)
-
-
#
# Virtual chassis
#
@@ -4099,20 +3911,83 @@ class DeviceSelectionForm(forms.Form):
)
-class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
- tags = TagField(
+class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False
+ )
+ rack = DynamicModelChoiceField(
+ queryset=Rack.objects.all(),
+ required=False,
+ null_option='None',
+ display_field='display_name',
+ query_params={
+ 'site_id': '$site'
+ }
+ )
+ members = DynamicModelMultipleChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ display_field='display_name',
+ query_params={
+ 'site_id': '$site',
+ 'rack_id': '$rack',
+ }
+ )
+ initial_position = forms.IntegerField(
+ initial=1,
+ required=False,
+ help_text='Position of the first member device. Increases by one for each additional member.'
+ )
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
class Meta:
model = VirtualChassis
fields = [
- 'master', 'domain', 'tags',
+ 'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags',
+ ]
+
+ def save(self, *args, **kwargs):
+ instance = super().save(*args, **kwargs)
+
+ # Assign VC members
+ if instance.pk:
+ initial_position = self.cleaned_data.get('initial_position') or 1
+ for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
+ member.virtual_chassis = instance
+ member.vc_position = i
+ member.save()
+
+ return instance
+
+
+class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
+ master = forms.ModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ )
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+
+ class Meta:
+ model = VirtualChassis
+ fields = [
+ 'name', 'domain', 'master', 'tags',
]
widgets = {
'master': SelectWithPK(),
}
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.fields['master'].queryset = Device.objects.filter(virtual_chassis=self.instance)
+
class BaseVCMemberFormSet(forms.BaseModelFormSet):
@@ -4171,41 +4046,32 @@ class DeviceVCMembershipForm(forms.ModelForm):
class VCMemberSelectForm(BootstrapMixin, forms.Form):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
- widget=APISelect(
- filter_for={
- 'rack': 'site_id',
- 'device': 'site_id',
- }
- )
+ required=False
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
- widget=APISelect(
- filter_for={
- 'device': 'rack_id'
- },
- attrs={
- 'nullable': 'true',
- }
- )
+ null_option='None',
+ display_field='display_name',
+ query_params={
+ 'site_id': '$site'
+ }
)
device = DynamicModelChoiceField(
- queryset=Device.objects.filter(
- virtual_chassis__isnull=True
- ),
- widget=APISelect(
- display_field='display_name',
- disabled_indicator='virtual_chassis'
- )
+ queryset=Device.objects.all(),
+ display_field='display_name',
+ query_params={
+ 'site_id': '$site',
+ 'rack_id': '$rack',
+ 'virtual_chassis_id': 'null',
+ }
)
def clean_device(self):
device = self.cleaned_data['device']
if device.virtual_chassis is not None:
raise forms.ValidationError(
- "Device {} is already assigned to a virtual chassis.".format(device)
+ f"Device {device} is already assigned to a virtual chassis."
)
return device
@@ -4224,6 +4090,19 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm
nullable_fields = ['domain']
+class VirtualChassisCSVForm(CSVModelForm):
+ master = CSVModelChoiceField(
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text='Master device'
+ )
+
+ class Meta:
+ model = VirtualChassis
+ fields = VirtualChassis.csv_headers
+
+
class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VirtualChassis
q = forms.CharField(
@@ -4233,42 +4112,30 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region'
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- )
+ query_params={
+ 'region': '$region'
+ }
)
tenant_group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- null_option=True,
- filter_for={
- 'tenant': 'group'
- }
- )
+ null_option='None'
)
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- null_option=True,
- )
+ null_option='None',
+ query_params={
+ 'group': '$tenant_group'
+ }
)
tag = TagFilterField(model)
@@ -4279,22 +4146,24 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
- queryset=Site.objects.all(),
- widget=APISelect(
- filter_for={
- 'rack_group': 'site_id',
- }
- )
+ queryset=Site.objects.all()
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
+ )
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
class Meta:
model = PowerPanel
fields = [
- 'site', 'rack_group', 'name',
+ 'site', 'rack_group', 'name', 'tags',
]
@@ -4324,23 +4193,21 @@ class PowerPanelCSVForm(CSVModelForm):
self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
-class PowerPanelBulkEditForm(BootstrapMixin, BulkEditForm):
+class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
widget=forms.MultipleHiddenInput
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
- widget=APISelect(
- filter_for={
- 'rack_group': 'site_id',
- }
- )
+ required=False
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
)
class Meta:
@@ -4358,33 +4225,26 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region'
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'rack_group_id': 'site',
- }
- )
+ query_params={
+ 'region': '$region'
+ }
)
rack_group_id = DynamicModelMultipleChoiceField(
queryset=RackGroup.objects.all(),
required=False,
label='Rack group (ID)',
- widget=APISelectMultiple(
- null_option=True,
- )
+ null_option='None',
+ query_params={
+ 'site': '$site'
+ }
)
+ tag = TagFilterField(model)
#
@@ -4394,23 +4254,25 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
- required=False,
- widget=APISelect(
- filter_for={
- 'power_panel': 'site_id',
- 'rack': 'site_id',
- }
- )
+ required=False
)
power_panel = DynamicModelChoiceField(
- queryset=PowerPanel.objects.all()
+ queryset=PowerPanel.objects.all(),
+ query_params={
+ 'site_id': '$site'
+ }
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
- required=False
+ required=False,
+ display_field='display_name',
+ query_params={
+ 'site_id': '$site'
+ }
)
comments = CommentField()
- tags = TagField(
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
required=False
)
@@ -4512,16 +4374,12 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
)
power_panel = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
- required=False,
- widget=APISelect(
- filter_for={
- 'rackgroup': 'site_id',
- }
- )
+ required=False
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
- required=False
+ required=False,
+ display_field='display_name'
)
status = forms.ChoiceField(
choices=add_blank_choice(PowerFeedStatusChoices),
@@ -4576,41 +4434,33 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
- required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'site': 'region'
- }
- )
+ required=False
)
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
- widget=APISelectMultiple(
- value_field="slug",
- filter_for={
- 'power_panel_id': 'site',
- 'rack_id': 'site',
- }
- )
+ query_params={
+ 'region': '$region'
+ }
)
power_panel_id = DynamicModelMultipleChoiceField(
queryset=PowerPanel.objects.all(),
required=False,
label='Power panel',
- widget=APISelectMultiple(
- null_option=True,
- )
+ null_option='None',
+ query_params={
+ 'site': '$site'
+ }
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
label='Rack',
- widget=APISelectMultiple(
- null_option=True,
- )
+ null_option='None',
+ query_params={
+ 'site': '$site'
+ }
)
status = forms.MultipleChoiceField(
choices=PowerFeedStatusChoices,
diff --git a/netbox/dcim/migrations/0041_napalm_integration.py b/netbox/dcim/migrations/0041_napalm_integration.py
index 50c2fbd99..3acad9f0b 100644
--- a/netbox/dcim/migrations/0041_napalm_integration.py
+++ b/netbox/dcim/migrations/0041_napalm_integration.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
- options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
+ options={'ordering': ['name']},
),
migrations.AddField(
model_name='platform',
diff --git a/netbox/dcim/migrations/0089_deterministic_ordering.py b/netbox/dcim/migrations/0089_deterministic_ordering.py
index 6944cff00..77d18739e 100644
--- a/netbox/dcim/migrations/0089_deterministic_ordering.py
+++ b/netbox/dcim/migrations/0089_deterministic_ordering.py
@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
- options={'ordering': ('name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
+ options={'ordering': ('name', 'pk')},
),
migrations.AlterModelOptions(
name='rack',
diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py
index 4e3c941a1..925694958 100644
--- a/netbox/dcim/migrations/0093_device_component_ordering.py
+++ b/netbox/dcim/migrations/0093_device_component_ordering.py
@@ -79,42 +79,42 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='consoleport',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverport',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebay',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontport',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='inventoryitem',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlet',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerport',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearport',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleports,
diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py
index 24fe98e94..70acd3189 100644
--- a/netbox/dcim/migrations/0094_device_component_template_ordering.py
+++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py
@@ -75,37 +75,37 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='consoleporttemplate',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebaytemplate',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontporttemplate',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerporttemplate',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearporttemplate',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleporttemplates,
diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py
index 3bc780161..2d6be72c8 100644
--- a/netbox/dcim/migrations/0095_primary_model_ordering.py
+++ b/netbox/dcim/migrations/0095_primary_model_ordering.py
@@ -30,7 +30,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
- options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
+ options={'ordering': ('_name', 'pk')},
),
migrations.AlterModelOptions(
name='rack',
@@ -43,17 +43,17 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='device',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
),
migrations.AddField(
model_name='rack',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='site',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_sites,
diff --git a/netbox/dcim/migrations/0096_interface_ordering.py b/netbox/dcim/migrations/0096_interface_ordering.py
index f1622f504..7b2663c95 100644
--- a/netbox/dcim/migrations/0096_interface_ordering.py
+++ b/netbox/dcim/migrations/0096_interface_ordering.py
@@ -35,12 +35,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='interface',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.AddField(
model_name='interfacetemplate',
name='_name',
- field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
+ field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.RunPython(
code=naturalize_interfacetemplates,
diff --git a/netbox/dcim/migrations/0107_component_labels.py b/netbox/dcim/migrations/0107_component_labels.py
new file mode 100644
index 000000000..c89bfc0b6
--- /dev/null
+++ b/netbox/dcim/migrations/0107_component_labels.py
@@ -0,0 +1,96 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0106_role_default_color'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='consoleport',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='consoleporttemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='consoleserverport',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='consoleserverporttemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='devicebay',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='devicebaytemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='frontport',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='frontporttemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='interfacetemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='inventoryitem',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='poweroutlet',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='poweroutlettemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='powerport',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='powerporttemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='rearport',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='rearporttemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0108_add_tags.py b/netbox/dcim/migrations/0108_add_tags.py
new file mode 100644
index 000000000..670f1f0e9
--- /dev/null
+++ b/netbox/dcim/migrations/0108_add_tags.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.0.6 on 2020-06-10 18:32
+
+from django.db import migrations
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0042_customfield_manager'),
+ ('dcim', '0107_component_labels'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cable',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='powerpanel',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ migrations.AddField(
+ model_name='rackreservation',
+ name='tags',
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0109_interface_remove_vm.py b/netbox/dcim/migrations/0109_interface_remove_vm.py
new file mode 100644
index 000000000..6e1d727b0
--- /dev/null
+++ b/netbox/dcim/migrations/0109_interface_remove_vm.py
@@ -0,0 +1,24 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0108_add_tags'),
+ ('virtualization', '0016_replicate_interfaces'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='interface',
+ name='virtual_machine',
+ ),
+ # device is now a required field
+ migrations.AlterField(
+ model_name='interface',
+ name='device',
+ field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
+ preserve_default=False,
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0110_virtualchassis_name.py b/netbox/dcim/migrations/0110_virtualchassis_name.py
new file mode 100644
index 000000000..e8455d6fe
--- /dev/null
+++ b/netbox/dcim/migrations/0110_virtualchassis_name.py
@@ -0,0 +1,46 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def copy_master_name(apps, schema_editor):
+ """
+ Copy the master device's name to the VirtualChassis.
+ """
+ VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
+
+ for vc in VirtualChassis.objects.prefetch_related('master'):
+ name = vc.master.name if vc.master.name else f'Unnamed VC #{vc.pk}'
+ VirtualChassis.objects.filter(pk=vc.pk).update(name=name)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0109_interface_remove_vm'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='virtualchassis',
+ options={'ordering': ['name'], 'verbose_name_plural': 'virtual chassis'},
+ ),
+ migrations.AddField(
+ model_name='virtualchassis',
+ name='name',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='virtualchassis',
+ name='master',
+ field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
+ ),
+ migrations.RunPython(
+ code=copy_master_name,
+ reverse_code=migrations.RunPython.noop
+ ),
+ migrations.AlterField(
+ model_name='virtualchassis',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0111_component_template_description.py b/netbox/dcim/migrations/0111_component_template_description.py
new file mode 100644
index 000000000..3040f586c
--- /dev/null
+++ b/netbox/dcim/migrations/0111_component_template_description.py
@@ -0,0 +1,53 @@
+# Generated by Django 3.0.6 on 2020-06-30 18:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0110_virtualchassis_name'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='consoleporttemplate',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='consoleserverporttemplate',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='devicebaytemplate',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='frontporttemplate',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='interfacetemplate',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='poweroutlettemplate',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='powerporttemplate',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='rearporttemplate',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0112_standardize_components.py b/netbox/dcim/migrations/0112_standardize_components.py
new file mode 100644
index 000000000..1a3465e02
--- /dev/null
+++ b/netbox/dcim/migrations/0112_standardize_components.py
@@ -0,0 +1,120 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0111_component_template_description'),
+ ]
+
+ operations = [
+ # Set max_length=64 for all name fields
+ migrations.AlterField(
+ model_name='consoleport',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='consoleporttemplate',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='consoleserverport',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='consoleserverporttemplate',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='devicebay',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='devicebaytemplate',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='inventoryitem',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='poweroutlet',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='poweroutlettemplate',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='powerport',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+ migrations.AlterField(
+ model_name='powerporttemplate',
+ name='name',
+ field=models.CharField(max_length=64),
+ ),
+
+ # Update related_name for necessary component and component template models
+ migrations.AlterField(
+ model_name='consoleporttemplate',
+ name='device_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.DeviceType'),
+ ),
+ migrations.AlterField(
+ model_name='consoleserverporttemplate',
+ name='device_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.DeviceType'),
+ ),
+ migrations.AlterField(
+ model_name='devicebay',
+ name='device',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebays', to='dcim.Device'),
+ ),
+ migrations.AlterField(
+ model_name='devicebaytemplate',
+ name='device_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebaytemplates', to='dcim.DeviceType'),
+ ),
+ migrations.AlterField(
+ model_name='frontporttemplate',
+ name='device_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.DeviceType'),
+ ),
+ migrations.AlterField(
+ model_name='interfacetemplate',
+ name='device_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.DeviceType'),
+ ),
+ migrations.AlterField(
+ model_name='inventoryitem',
+ name='device',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitems', to='dcim.Device'),
+ ),
+ migrations.AlterField(
+ model_name='poweroutlettemplate',
+ name='device_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.DeviceType'),
+ ),
+ migrations.AlterField(
+ model_name='powerporttemplate',
+ name='device_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.DeviceType'),
+ ),
+ migrations.AlterField(
+ model_name='rearporttemplate',
+ name='device_type',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.DeviceType'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0113_nullbooleanfield_to_booleanfield.py b/netbox/dcim/migrations/0113_nullbooleanfield_to_booleanfield.py
new file mode 100644
index 000000000..b96e2dcd4
--- /dev/null
+++ b/netbox/dcim/migrations/0113_nullbooleanfield_to_booleanfield.py
@@ -0,0 +1,43 @@
+# Generated by Django 3.1b1 on 2020-07-16 15:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0112_standardize_components'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='consoleport',
+ name='connection_status',
+ field=models.BooleanField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='consoleserverport',
+ name='connection_status',
+ field=models.BooleanField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='interface',
+ name='connection_status',
+ field=models.BooleanField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='powerfeed',
+ name='connection_status',
+ field=models.BooleanField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='poweroutlet',
+ name='connection_status',
+ field=models.BooleanField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='powerport',
+ name='connection_status',
+ field=models.BooleanField(blank=True, null=True),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0114_update_jsonfield.py b/netbox/dcim/migrations/0114_update_jsonfield.py
new file mode 100644
index 000000000..5a971bced
--- /dev/null
+++ b/netbox/dcim/migrations/0114_update_jsonfield.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.1b1 on 2020-07-16 16:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0113_nullbooleanfield_to_booleanfield'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='device',
+ name='local_context_data',
+ field=models.JSONField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='platform',
+ name='napalm_args',
+ field=models.JSONField(blank=True, null=True),
+ ),
+ ]
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index ef5b07aca..e50fa2eda 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -1,43 +1,12 @@
-from collections import OrderedDict
-from itertools import count, groupby
-
-import yaml
-from django.conf import settings
-from django.contrib.auth.models import User
-from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
-from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.fields import ArrayField, JSONField
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
-from django.core.validators import MaxValueValidator, MinValueValidator
-from django.db import models
-from django.db.models import Count, F, ProtectedError, Sum
-from django.urls import reverse
-from django.utils.safestring import mark_safe
-from mptt.models import MPTTModel, TreeForeignKey
-from taggit.managers import TaggableManager
-from timezone_field import TimeZoneField
-
-from dcim.choices import *
-from dcim.constants import *
-from dcim.fields import ASNField
-from dcim.elevations import RackElevationSVG
-from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
-from extras.utils import extras_features
-from utilities.choices import ColorChoices
-from utilities.fields import ColorField, NaturalOrderingField
-from utilities.models import ChangeLoggedModel
-from utilities.utils import serialize_object, to_meters
-from utilities.validators import ExclusionValidator
-from .device_component_templates import (
- ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
- PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
-)
-from .device_components import (
- CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
- PowerPort, RearPort,
-)
+from .device_component_templates import *
+from .device_components import *
+from .devices import *
+from .power import *
+from .racks import *
+from .sites import *
__all__ = (
+ 'BaseInterface',
'Cable',
'CableTermination',
'ConsolePort',
@@ -72,2217 +41,3 @@ __all__ = (
'Site',
'VirtualChassis',
)
-
-
-#
-# Regions
-#
-
-@extras_features('export_templates', 'webhooks')
-class Region(MPTTModel, ChangeLoggedModel):
- """
- Sites can be grouped within geographic Regions.
- """
- parent = TreeForeignKey(
- to='self',
- on_delete=models.CASCADE,
- related_name='children',
- blank=True,
- null=True,
- db_index=True
- )
- name = models.CharField(
- max_length=50,
- unique=True
- )
- slug = models.SlugField(
- unique=True
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
-
- csv_headers = ['name', 'slug', 'parent', 'description']
-
- class MPTTMeta:
- order_insertion_by = ['name']
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
-
- def to_csv(self):
- return (
- self.name,
- self.slug,
- self.parent.name if self.parent else None,
- self.description,
- )
-
- def get_site_count(self):
- return Site.objects.filter(
- Q(region=self) |
- Q(region__in=self.get_descendants())
- ).count()
-
- def to_objectchange(self, action):
- # Remove MPTT-internal fields
- return ObjectChange(
- changed_object=self,
- object_repr=str(self),
- action=action,
- object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
- )
-
-
-#
-# Sites
-#
-
-@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
-class Site(ChangeLoggedModel, CustomFieldModel):
- """
- A Site represents a geographic location within a network; typically a building or campus. The optional facility
- field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
- """
- name = models.CharField(
- max_length=50,
- unique=True
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
- slug = models.SlugField(
- unique=True
- )
- status = models.CharField(
- max_length=50,
- choices=SiteStatusChoices,
- default=SiteStatusChoices.STATUS_ACTIVE
- )
- region = models.ForeignKey(
- to='dcim.Region',
- on_delete=models.SET_NULL,
- related_name='sites',
- blank=True,
- null=True
- )
- tenant = models.ForeignKey(
- to='tenancy.Tenant',
- on_delete=models.PROTECT,
- related_name='sites',
- blank=True,
- null=True
- )
- facility = models.CharField(
- max_length=50,
- blank=True,
- help_text='Local facility ID or description'
- )
- asn = ASNField(
- blank=True,
- null=True,
- verbose_name='ASN',
- help_text='32-bit autonomous system number'
- )
- time_zone = TimeZoneField(
- blank=True
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
- physical_address = models.CharField(
- max_length=200,
- blank=True
- )
- shipping_address = models.CharField(
- max_length=200,
- blank=True
- )
- latitude = models.DecimalField(
- max_digits=8,
- decimal_places=6,
- blank=True,
- null=True,
- help_text='GPS coordinate (latitude)'
- )
- longitude = models.DecimalField(
- max_digits=9,
- decimal_places=6,
- blank=True,
- null=True,
- help_text='GPS coordinate (longitude)'
- )
- contact_name = models.CharField(
- max_length=50,
- blank=True
- )
- contact_phone = models.CharField(
- max_length=20,
- blank=True
- )
- contact_email = models.EmailField(
- blank=True,
- verbose_name='Contact E-mail'
- )
- comments = models.TextField(
- blank=True
- )
- custom_field_values = GenericRelation(
- to='extras.CustomFieldValue',
- content_type_field='obj_type',
- object_id_field='obj_id'
- )
- images = GenericRelation(
- to='extras.ImageAttachment'
- )
- tags = TaggableManager(through=TaggedItem)
-
- csv_headers = [
- 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
- 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
- ]
- clone_fields = [
- 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
- 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
- ]
-
- STATUS_CLASS_MAP = {
- SiteStatusChoices.STATUS_PLANNED: 'info',
- SiteStatusChoices.STATUS_STAGING: 'primary',
- SiteStatusChoices.STATUS_ACTIVE: 'success',
- SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
- SiteStatusChoices.STATUS_RETIRED: 'danger',
- }
-
- class Meta:
- ordering = ('_name',)
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return reverse('dcim:site', args=[self.slug])
-
- def to_csv(self):
- return (
- self.name,
- self.slug,
- self.get_status_display(),
- self.region.name if self.region else None,
- self.tenant.name if self.tenant else None,
- self.facility,
- self.asn,
- self.time_zone,
- self.description,
- self.physical_address,
- self.shipping_address,
- self.latitude,
- self.longitude,
- self.contact_name,
- self.contact_phone,
- self.contact_email,
- self.comments,
- )
-
- def get_status_class(self):
- return self.STATUS_CLASS_MAP.get(self.status)
-
-
-#
-# Racks
-#
-
-@extras_features('export_templates')
-class RackGroup(MPTTModel, ChangeLoggedModel):
- """
- Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
- example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
- campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
- """
- name = models.CharField(
- max_length=50
- )
- slug = models.SlugField()
- site = models.ForeignKey(
- to='dcim.Site',
- on_delete=models.CASCADE,
- related_name='rack_groups'
- )
- parent = TreeForeignKey(
- to='self',
- on_delete=models.CASCADE,
- related_name='children',
- blank=True,
- null=True,
- db_index=True
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
-
- csv_headers = ['site', 'parent', 'name', 'slug', 'description']
-
- class Meta:
- ordering = ['site', 'name']
- unique_together = [
- ['site', 'name'],
- ['site', 'slug'],
- ]
-
- class MPTTMeta:
- order_insertion_by = ['name']
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
-
- def to_csv(self):
- return (
- self.site,
- self.parent.name if self.parent else '',
- self.name,
- self.slug,
- self.description,
- )
-
- def to_objectchange(self, action):
- # Remove MPTT-internal fields
- return ObjectChange(
- changed_object=self,
- object_repr=str(self),
- action=action,
- object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
- )
-
- def clean(self):
-
- # Parent RackGroup (if any) must belong to the same Site
- if self.parent and self.parent.site != self.site:
- raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
-
-
-class RackRole(ChangeLoggedModel):
- """
- Racks can be organized by functional role, similar to Devices.
- """
- name = models.CharField(
- max_length=50,
- unique=True
- )
- slug = models.SlugField(
- unique=True
- )
- color = ColorField(
- default=ColorChoices.COLOR_GREY
- )
- description = models.CharField(
- max_length=200,
- blank=True,
- )
-
- csv_headers = ['name', 'slug', 'color', 'description']
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
-
- def to_csv(self):
- return (
- self.name,
- self.slug,
- self.color,
- self.description,
- )
-
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
-class Rack(ChangeLoggedModel, CustomFieldModel):
- """
- Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
- Each Rack is assigned to a Site and (optionally) a RackGroup.
- """
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
- facility_id = models.CharField(
- max_length=50,
- blank=True,
- null=True,
- verbose_name='Facility ID',
- help_text='Locally-assigned identifier'
- )
- site = models.ForeignKey(
- to='dcim.Site',
- on_delete=models.PROTECT,
- related_name='racks'
- )
- group = models.ForeignKey(
- to='dcim.RackGroup',
- on_delete=models.SET_NULL,
- related_name='racks',
- blank=True,
- null=True,
- help_text='Assigned group'
- )
- tenant = models.ForeignKey(
- to='tenancy.Tenant',
- on_delete=models.PROTECT,
- related_name='racks',
- blank=True,
- null=True
- )
- status = models.CharField(
- max_length=50,
- choices=RackStatusChoices,
- default=RackStatusChoices.STATUS_ACTIVE
- )
- role = models.ForeignKey(
- to='dcim.RackRole',
- on_delete=models.PROTECT,
- related_name='racks',
- blank=True,
- null=True,
- help_text='Functional role'
- )
- serial = models.CharField(
- max_length=50,
- blank=True,
- verbose_name='Serial number'
- )
- asset_tag = models.CharField(
- max_length=50,
- blank=True,
- null=True,
- unique=True,
- verbose_name='Asset tag',
- help_text='A unique tag used to identify this rack'
- )
- type = models.CharField(
- choices=RackTypeChoices,
- max_length=50,
- blank=True,
- verbose_name='Type'
- )
- width = models.PositiveSmallIntegerField(
- choices=RackWidthChoices,
- default=RackWidthChoices.WIDTH_19IN,
- verbose_name='Width',
- help_text='Rail-to-rail width'
- )
- u_height = models.PositiveSmallIntegerField(
- default=RACK_U_HEIGHT_DEFAULT,
- verbose_name='Height (U)',
- validators=[MinValueValidator(1), MaxValueValidator(100)],
- help_text='Height in rack units'
- )
- desc_units = models.BooleanField(
- default=False,
- verbose_name='Descending units',
- help_text='Units are numbered top-to-bottom'
- )
- outer_width = models.PositiveSmallIntegerField(
- blank=True,
- null=True,
- help_text='Outer dimension of rack (width)'
- )
- outer_depth = models.PositiveSmallIntegerField(
- blank=True,
- null=True,
- help_text='Outer dimension of rack (depth)'
- )
- outer_unit = models.CharField(
- max_length=50,
- choices=RackDimensionUnitChoices,
- blank=True,
- )
- comments = models.TextField(
- blank=True
- )
- custom_field_values = GenericRelation(
- to='extras.CustomFieldValue',
- content_type_field='obj_type',
- object_id_field='obj_id'
- )
- images = GenericRelation(
- to='extras.ImageAttachment'
- )
- tags = TaggableManager(through=TaggedItem)
-
- csv_headers = [
- 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
- 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
- ]
- clone_fields = [
- 'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
- 'outer_depth', 'outer_unit',
- ]
-
- STATUS_CLASS_MAP = {
- RackStatusChoices.STATUS_RESERVED: 'warning',
- RackStatusChoices.STATUS_AVAILABLE: 'success',
- RackStatusChoices.STATUS_PLANNED: 'info',
- RackStatusChoices.STATUS_ACTIVE: 'primary',
- RackStatusChoices.STATUS_DEPRECATED: 'danger',
- }
-
- class Meta:
- ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
- unique_together = (
- # Name and facility_id must be unique *only* within a RackGroup
- ('group', 'name'),
- ('group', 'facility_id'),
- )
-
- def __str__(self):
- return self.display_name or super().__str__()
-
- def get_absolute_url(self):
- return reverse('dcim:rack', args=[self.pk])
-
- def clean(self):
-
- # Validate outer dimensions and unit
- if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
- raise ValidationError("Must specify a unit when setting an outer width/depth")
- elif self.outer_width is None and self.outer_depth is None:
- self.outer_unit = ''
-
- if self.pk:
- # Validate that Rack is tall enough to house the installed Devices
- top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
- if top_device:
- min_height = top_device.position + top_device.device_type.u_height - 1
- if self.u_height < min_height:
- raise ValidationError({
- 'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
- min_height
- )
- })
- # Validate that Rack was assigned a group of its same site, if applicable
- if self.group:
- if self.group.site != self.site:
- raise ValidationError({
- 'group': "Rack group must be from the same site, {}.".format(self.site)
- })
-
- def save(self, *args, **kwargs):
-
- # Record the original site assignment for this rack.
- _site_id = None
- if self.pk:
- _site_id = Rack.objects.get(pk=self.pk).site_id
-
- super().save(*args, **kwargs)
-
- # Update racked devices if the assigned Site has been changed.
- if _site_id is not None and self.site_id != _site_id:
- devices = Device.objects.filter(rack=self)
- for device in devices:
- device.site = self.site
- device.save()
-
- def to_csv(self):
- return (
- self.site.name,
- self.group.name if self.group else None,
- self.name,
- self.facility_id,
- self.tenant.name if self.tenant else None,
- self.get_status_display(),
- self.role.name if self.role else None,
- self.get_type_display() if self.type else None,
- self.serial,
- self.asset_tag,
- self.width,
- self.u_height,
- self.desc_units,
- self.outer_width,
- self.outer_depth,
- self.outer_unit,
- self.comments,
- )
-
- @property
- def units(self):
- if self.desc_units:
- return range(1, self.u_height + 1)
- else:
- return reversed(range(1, self.u_height + 1))
-
- @property
- def display_name(self):
- if self.facility_id:
- return "{} ({})".format(self.name, self.facility_id)
- elif self.name:
- return self.name
- return ""
-
- def get_status_class(self):
- return self.STATUS_CLASS_MAP.get(self.status)
-
- def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
- """
- Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
- Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
-
- :param face: Rack face (front or rear)
- :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
- :param expand_devices: When True, all units that a device occupies will be listed with each containing a
- reference to the device. When False, only the bottom most unit for a device is included and that unit
- contains a height attribute for the device
- """
-
- elevation = OrderedDict()
- for u in self.units:
- elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None}
-
- # Add devices to rack units list
- if self.pk:
- queryset = Device.objects.prefetch_related(
- 'device_type',
- 'device_type__manufacturer',
- 'device_role'
- ).annotate(
- devicebay_count=Count('device_bays')
- ).exclude(
- pk=exclude
- ).filter(
- rack=self,
- position__gt=0,
- device_type__u_height__gt=0
- ).filter(
- Q(face=face) | Q(device_type__is_full_depth=True)
- )
- for device in queryset:
- if expand_devices:
- for u in range(device.position, device.position + device.device_type.u_height):
- elevation[u]['device'] = device
- else:
- elevation[device.position]['device'] = device
- elevation[device.position]['height'] = device.device_type.u_height
- for u in range(device.position + 1, device.position + device.device_type.u_height):
- elevation.pop(u, None)
-
- return [u for u in elevation.values()]
-
- def get_available_units(self, u_height=1, rack_face=None, exclude=list()):
- """
- Return a list of units within the rack available to accommodate a device of a given U height (default 1).
- Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
- position to another within a rack).
-
- :param u_height: Minimum number of contiguous free units required
- :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
- :param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
- """
-
- # Gather all devices which consume U space within the rack
- devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
-
- # Initialize the rack unit skeleton
- units = list(range(1, self.u_height + 1))
-
- # Remove units consumed by installed devices
- for d in devices:
- if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
- for u in range(d.position, d.position + d.device_type.u_height):
- try:
- units.remove(u)
- except ValueError:
- # Found overlapping devices in the rack!
- pass
-
- # Remove units without enough space above them to accommodate a device of the specified height
- available_units = []
- for u in units:
- if set(range(u, u + u_height)).issubset(units):
- available_units.append(u)
-
- return list(reversed(available_units))
-
- def get_reserved_units(self):
- """
- Return a dictionary mapping all reserved units within the rack to their reservation.
- """
- reserved_units = {}
- for r in self.reservations.all():
- for u in r.units:
- reserved_units[u] = r
- return reserved_units
-
- def get_elevation_svg(
- self,
- face=DeviceFaceChoices.FACE_FRONT,
- unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
- unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
- legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
- include_images=True,
- base_url=None
- ):
- """
- Return an SVG of the rack elevation
-
- :param face: Enum of [front, rear] representing the desired side of the rack elevation to render
- :param unit_width: Width in pixels for the rendered drawing
- :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
- height of the elevation
- :param legend_width: Width of the unit legend, in pixels
- :param include_images: Embed front/rear device images where available
- :param base_url: Base URL for links and images. If none, URLs will be relative.
- """
- elevation = RackElevationSVG(self, include_images=include_images, base_url=base_url)
-
- return elevation.render(face, unit_width, unit_height, legend_width)
-
- def get_0u_devices(self):
- return self.devices.filter(position=0)
-
- def get_utilization(self):
- """
- Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
- as utilized.
- """
- # Determine unoccupied units
- available_units = self.get_available_units()
-
- # Remove reserved units
- for u in self.get_reserved_units():
- if u in available_units:
- available_units.remove(u)
-
- occupied_unit_count = self.u_height - len(available_units)
- percentage = int(float(occupied_unit_count) / self.u_height * 100)
-
- return percentage
-
- 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'] or 0 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
-
-
-@extras_features('custom_links', 'export_templates', 'webhooks')
-class RackReservation(ChangeLoggedModel):
- """
- One or more reserved units within a Rack.
- """
- rack = models.ForeignKey(
- to='dcim.Rack',
- on_delete=models.CASCADE,
- related_name='reservations'
- )
- units = ArrayField(
- base_field=models.PositiveSmallIntegerField()
- )
- tenant = models.ForeignKey(
- to='tenancy.Tenant',
- on_delete=models.PROTECT,
- related_name='rackreservations',
- blank=True,
- null=True
- )
- user = models.ForeignKey(
- to=User,
- on_delete=models.PROTECT
- )
- description = models.CharField(
- max_length=200
- )
-
- csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
-
- class Meta:
- ordering = ['created']
-
- def __str__(self):
- return "Reservation for rack {}".format(self.rack)
-
- def get_absolute_url(self):
- return reverse('dcim:rackreservation', args=[self.pk])
-
- def clean(self):
-
- if hasattr(self, 'rack') and self.units:
-
- # Validate that all specified units exist in the Rack.
- invalid_units = [u for u in self.units if u not in self.rack.units]
- if invalid_units:
- raise ValidationError({
- 'units': "Invalid unit(s) for {}U rack: {}".format(
- self.rack.u_height,
- ', '.join([str(u) for u in invalid_units]),
- ),
- })
-
- # Check that none of the units has already been reserved for this Rack.
- reserved_units = []
- for resv in self.rack.reservations.exclude(pk=self.pk):
- reserved_units += resv.units
- conflicting_units = [u for u in self.units if u in reserved_units]
- if conflicting_units:
- raise ValidationError({
- 'units': 'The following units have already been reserved: {}'.format(
- ', '.join([str(u) for u in conflicting_units]),
- )
- })
-
- def to_csv(self):
- return (
- self.rack.site.name,
- self.rack.group if self.rack.group else None,
- self.rack.name,
- ','.join([str(u) for u in self.units]),
- self.tenant.name if self.tenant else None,
- self.user.username,
- self.description
- )
-
- @property
- def unit_list(self):
- """
- Express the assigned units as a string of summarized ranges. For example:
- [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
- """
- group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
- return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
-
-
-#
-# Device Types
-#
-
-@extras_features('export_templates', 'webhooks')
-class Manufacturer(ChangeLoggedModel):
- """
- A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
- """
- name = models.CharField(
- max_length=50,
- unique=True
- )
- slug = models.SlugField(
- unique=True
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
-
- csv_headers = ['name', 'slug', 'description']
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
-
- def to_csv(self):
- return (
- self.name,
- self.slug,
- self.description
- )
-
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
-class DeviceType(ChangeLoggedModel, CustomFieldModel):
- """
- A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
- well as high-level functional role(s).
-
- Each DeviceType can have an arbitrary number of component templates assigned to it, which define console, power, and
- interface objects. For example, a Juniper EX4300-48T DeviceType would have:
-
- * 1 ConsolePortTemplate
- * 2 PowerPortTemplates
- * 48 InterfaceTemplates
-
- When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the
- DeviceType) are automatically created as well.
- """
- manufacturer = models.ForeignKey(
- to='dcim.Manufacturer',
- on_delete=models.PROTECT,
- related_name='device_types'
- )
- model = models.CharField(
- max_length=50
- )
- slug = models.SlugField()
- part_number = models.CharField(
- max_length=50,
- blank=True,
- help_text='Discrete part number (optional)'
- )
- u_height = models.PositiveSmallIntegerField(
- default=1,
- verbose_name='Height (U)'
- )
- is_full_depth = models.BooleanField(
- default=True,
- verbose_name='Is full depth',
- help_text='Device consumes both front and rear rack faces'
- )
- subdevice_role = models.CharField(
- max_length=50,
- choices=SubdeviceRoleChoices,
- blank=True,
- verbose_name='Parent/child status',
- help_text='Parent devices house child devices in device bays. Leave blank '
- 'if this device type is neither a parent nor a child.'
- )
- front_image = models.ImageField(
- upload_to='devicetype-images',
- blank=True
- )
- rear_image = models.ImageField(
- upload_to='devicetype-images',
- blank=True
- )
- 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)
-
- clone_fields = [
- 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
- ]
-
- class Meta:
- ordering = ['manufacturer', 'model']
- unique_together = [
- ['manufacturer', 'model'],
- ['manufacturer', 'slug'],
- ]
-
- def __str__(self):
- return self.model
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Save a copy of u_height for validation in clean()
- self._original_u_height = self.u_height
-
- # Save references to the original front/rear images
- self._original_front_image = self.front_image
- self._original_rear_image = self.rear_image
-
- def get_absolute_url(self):
- return reverse('dcim:devicetype', args=[self.pk])
-
- def to_yaml(self):
- data = OrderedDict((
- ('manufacturer', self.manufacturer.name),
- ('model', self.model),
- ('slug', self.slug),
- ('part_number', self.part_number),
- ('u_height', self.u_height),
- ('is_full_depth', self.is_full_depth),
- ('subdevice_role', self.subdevice_role),
- ('comments', self.comments),
- ))
-
- # Component templates
- if self.consoleport_templates.exists():
- data['console-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- }
- for c in self.consoleport_templates.all()
- ]
- if self.consoleserverport_templates.exists():
- data['console-server-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- }
- for c in self.consoleserverport_templates.all()
- ]
- if self.powerport_templates.exists():
- data['power-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'maximum_draw': c.maximum_draw,
- 'allocated_draw': c.allocated_draw,
- }
- for c in self.powerport_templates.all()
- ]
- if self.poweroutlet_templates.exists():
- data['power-outlets'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'power_port': c.power_port.name if c.power_port else None,
- 'feed_leg': c.feed_leg,
- }
- for c in self.poweroutlet_templates.all()
- ]
- if self.interface_templates.exists():
- data['interfaces'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'mgmt_only': c.mgmt_only,
- }
- for c in self.interface_templates.all()
- ]
- if self.frontport_templates.exists():
- data['front-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'rear_port': c.rear_port.name,
- 'rear_port_position': c.rear_port_position,
- }
- for c in self.frontport_templates.all()
- ]
- if self.rearport_templates.exists():
- data['rear-ports'] = [
- {
- 'name': c.name,
- 'type': c.type,
- 'positions': c.positions,
- }
- for c in self.rearport_templates.all()
- ]
- if self.device_bay_templates.exists():
- data['device-bays'] = [
- {
- 'name': c.name,
- }
- for c in self.device_bay_templates.all()
- ]
-
- return yaml.dump(dict(data), sort_keys=False)
-
- def clean(self):
-
- # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
- # room to expand within their racks. This validation will impose a very high performance penalty when there are
- # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
- if self.pk and self.u_height > self._original_u_height:
- for d in Device.objects.filter(device_type=self, position__isnull=False):
- face_required = None if self.is_full_depth else d.face
- u_available = d.rack.get_available_units(
- u_height=self.u_height,
- rack_face=face_required,
- exclude=[d.pk]
- )
- if d.position not in u_available:
- raise ValidationError({
- 'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
- "{}U".format(d, d.rack, self.u_height)
- })
-
- # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
- elif self.pk and self._original_u_height > 0 and self.u_height == 0:
- racked_instance_count = Device.objects.filter(device_type=self, position__isnull=False).count()
- if racked_instance_count:
- url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
- raise ValidationError({
- 'u_height': mark_safe(
- f'Unable to set 0U height: Found {racked_instance_count} instances already '
- f'mounted within racks.'
- )
- })
-
- if (
- self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
- ) and self.device_bay_templates.count():
- raise ValidationError({
- 'subdevice_role': "Must delete all device bay templates associated with this device before "
- "declassifying it as a parent device."
- })
-
- if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
- raise ValidationError({
- 'u_height': "Child device types must be 0U."
- })
-
- def save(self, *args, **kwargs):
- ret = super().save(*args, **kwargs)
-
- # Delete any previously uploaded image files that are no longer in use
- if self.front_image != self._original_front_image:
- self._original_front_image.delete(save=False)
- if self.rear_image != self._original_rear_image:
- self._original_rear_image.delete(save=False)
-
- return ret
-
- def delete(self, *args, **kwargs):
- super().delete(*args, **kwargs)
-
- # Delete any uploaded image files
- if self.front_image:
- self.front_image.delete(save=False)
- if self.rear_image:
- self.rear_image.delete(save=False)
-
- @property
- def display_name(self):
- return '{} {}'.format(self.manufacturer.name, self.model)
-
- @property
- def is_parent_device(self):
- return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT
-
- @property
- def is_child_device(self):
- return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
-
-
-#
-# Devices
-#
-
-class DeviceRole(ChangeLoggedModel):
- """
- Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
- color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
- virtual machines as well.
- """
- name = models.CharField(
- max_length=50,
- unique=True
- )
- slug = models.SlugField(
- unique=True
- )
- color = ColorField(
- default=ColorChoices.COLOR_GREY
- )
- vm_role = models.BooleanField(
- default=True,
- verbose_name='VM Role',
- help_text='Virtual machines may be assigned to this role'
- )
- description = models.CharField(
- max_length=200,
- blank=True,
- )
-
- csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
- def to_csv(self):
- return (
- self.name,
- self.slug,
- self.color,
- self.vm_role,
- self.description,
- )
-
-
-class Platform(ChangeLoggedModel):
- """
- Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
- NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
- specifying a NAPALM driver.
- """
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- unique=True,
- max_length=100
- )
- manufacturer = models.ForeignKey(
- to='dcim.Manufacturer',
- on_delete=models.PROTECT,
- related_name='platforms',
- blank=True,
- null=True,
- help_text='Optionally limit this platform to devices of a certain manufacturer'
- )
- napalm_driver = models.CharField(
- max_length=50,
- blank=True,
- verbose_name='NAPALM driver',
- help_text='The name of the NAPALM driver to use when interacting with devices'
- )
- napalm_args = JSONField(
- blank=True,
- null=True,
- verbose_name='NAPALM arguments',
- help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)'
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
-
- csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
- def get_absolute_url(self):
- return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
-
- def to_csv(self):
- return (
- self.name,
- self.slug,
- self.manufacturer.name if self.manufacturer else None,
- self.napalm_driver,
- self.napalm_args,
- self.description,
- )
-
-
-@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
-class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
- """
- A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
- DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
-
- Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a
- particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units).
-
- When a new Device is created, console/power/interface/device bay components are created along with it as dictated
- by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
- creation of a Device.
- """
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.PROTECT,
- related_name='instances'
- )
- device_role = models.ForeignKey(
- to='dcim.DeviceRole',
- on_delete=models.PROTECT,
- related_name='devices'
- )
- tenant = models.ForeignKey(
- to='tenancy.Tenant',
- on_delete=models.PROTECT,
- related_name='devices',
- blank=True,
- null=True
- )
- platform = models.ForeignKey(
- to='dcim.Platform',
- on_delete=models.SET_NULL,
- related_name='devices',
- blank=True,
- null=True
- )
- name = models.CharField(
- max_length=64,
- blank=True,
- null=True
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True,
- null=True
- )
- serial = models.CharField(
- max_length=50,
- blank=True,
- verbose_name='Serial number'
- )
- asset_tag = models.CharField(
- max_length=50,
- blank=True,
- null=True,
- unique=True,
- verbose_name='Asset tag',
- help_text='A unique tag used to identify this device'
- )
- site = models.ForeignKey(
- to='dcim.Site',
- on_delete=models.PROTECT,
- related_name='devices'
- )
- rack = models.ForeignKey(
- to='dcim.Rack',
- on_delete=models.PROTECT,
- related_name='devices',
- blank=True,
- null=True
- )
- position = models.PositiveSmallIntegerField(
- blank=True,
- null=True,
- validators=[MinValueValidator(1)],
- verbose_name='Position (U)',
- help_text='The lowest-numbered unit occupied by the device'
- )
- face = models.CharField(
- max_length=50,
- blank=True,
- choices=DeviceFaceChoices,
- verbose_name='Rack face'
- )
- status = models.CharField(
- max_length=50,
- choices=DeviceStatusChoices,
- default=DeviceStatusChoices.STATUS_ACTIVE
- )
- primary_ip4 = models.OneToOneField(
- to='ipam.IPAddress',
- on_delete=models.SET_NULL,
- related_name='primary_ip4_for',
- blank=True,
- null=True,
- verbose_name='Primary IPv4'
- )
- primary_ip6 = models.OneToOneField(
- to='ipam.IPAddress',
- on_delete=models.SET_NULL,
- related_name='primary_ip6_for',
- blank=True,
- null=True,
- verbose_name='Primary IPv6'
- )
- cluster = models.ForeignKey(
- to='virtualization.Cluster',
- on_delete=models.SET_NULL,
- related_name='devices',
- blank=True,
- null=True
- )
- virtual_chassis = models.ForeignKey(
- to='VirtualChassis',
- on_delete=models.SET_NULL,
- related_name='members',
- blank=True,
- null=True
- )
- vc_position = models.PositiveSmallIntegerField(
- blank=True,
- null=True,
- validators=[MaxValueValidator(255)]
- )
- vc_priority = models.PositiveSmallIntegerField(
- blank=True,
- null=True,
- validators=[MaxValueValidator(255)]
- )
- comments = models.TextField(
- blank=True
- )
- custom_field_values = GenericRelation(
- to='extras.CustomFieldValue',
- content_type_field='obj_type',
- object_id_field='obj_id'
- )
- images = GenericRelation(
- to='extras.ImageAttachment'
- )
- tags = TaggableManager(through=TaggedItem)
-
- csv_headers = [
- 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
- 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
- ]
- clone_fields = [
- 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster',
- ]
-
- STATUS_CLASS_MAP = {
- DeviceStatusChoices.STATUS_OFFLINE: 'warning',
- DeviceStatusChoices.STATUS_ACTIVE: 'success',
- DeviceStatusChoices.STATUS_PLANNED: 'info',
- DeviceStatusChoices.STATUS_STAGED: 'primary',
- DeviceStatusChoices.STATUS_FAILED: 'danger',
- DeviceStatusChoices.STATUS_INVENTORY: 'default',
- DeviceStatusChoices.STATUS_DECOMMISSIONING: 'warning',
- }
-
- class Meta:
- ordering = ('_name', 'pk') # Name may be null
- unique_together = (
- ('site', 'tenant', 'name'), # See validate_unique below
- ('rack', 'position', 'face'),
- ('virtual_chassis', 'vc_position'),
- )
- permissions = (
- ('napalm_read', 'Read-only access to devices via NAPALM'),
- ('napalm_write', 'Read/write access to devices via NAPALM'),
- )
-
- def __str__(self):
- return self.display_name or super().__str__()
-
- def get_absolute_url(self):
- return reverse('dcim:device', args=[self.pk])
-
- def validate_unique(self, exclude=None):
-
- # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
- # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
- # of the uniqueness constraint without manual intervention.
- if self.name and self.tenant is None:
- if Device.objects.exclude(pk=self.pk).filter(name=self.name, site=self.site, tenant__isnull=True):
- raise ValidationError({
- 'name': 'A device with this name already exists.'
- })
-
- super().validate_unique(exclude)
-
- def clean(self):
-
- super().clean()
-
- # Validate site/rack combination
- if self.rack and self.site != self.rack.site:
- raise ValidationError({
- 'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
- })
-
- if self.rack is None:
- if self.face:
- raise ValidationError({
- 'face': "Cannot select a rack face without assigning a rack.",
- })
- if self.position:
- raise ValidationError({
- 'face': "Cannot select a rack position without assigning a rack.",
- })
-
- # Validate position/face combination
- if self.position and not self.face:
- raise ValidationError({
- 'face': "Must specify rack face when defining rack position.",
- })
-
- # Prevent 0U devices from being assigned to a specific position
- if self.position and self.device_type.u_height == 0:
- raise ValidationError({
- 'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
- })
-
- if self.rack:
-
- try:
- # Child devices cannot be assigned to a rack face/unit
- if self.device_type.is_child_device and self.face:
- raise ValidationError({
- 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
- "parent device."
- })
- if self.device_type.is_child_device and self.position:
- raise ValidationError({
- 'position': "Child device types cannot be assigned to a rack position. This is an attribute of "
- "the parent device."
- })
-
- # Validate rack space
- rack_face = self.face if not self.device_type.is_full_depth else None
- exclude_list = [self.pk] if self.pk else []
- try:
- available_units = self.rack.get_available_units(
- u_height=self.device_type.u_height, rack_face=rack_face, exclude=exclude_list
- )
- if self.position and self.position not in available_units:
- raise ValidationError({
- 'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
- "{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
- })
- except Rack.DoesNotExist:
- pass
-
- except DeviceType.DoesNotExist:
- pass
-
- # Validate primary IP addresses
- vc_interfaces = self.vc_interfaces.all()
- if self.primary_ip4:
- if self.primary_ip4.family != 4:
- raise ValidationError({
- 'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
- })
- if self.primary_ip4.interface in vc_interfaces:
- pass
- elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
- pass
- else:
- raise ValidationError({
- 'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
- })
- if self.primary_ip6:
- if self.primary_ip6.family != 6:
- raise ValidationError({
- 'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
- })
- if self.primary_ip6.interface in vc_interfaces:
- pass
- elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
- pass
- else:
- raise ValidationError({
- 'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
- })
-
- # Validate manufacturer/platform
- if hasattr(self, 'device_type') and self.platform:
- if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
- raise ValidationError({
- 'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
- "to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
- })
-
- # A Device can only be assigned to a Cluster in the same Site (or no Site)
- if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
- raise ValidationError({
- 'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
- })
-
- # Validate virtual chassis assignment
- if self.virtual_chassis and self.vc_position is None:
- raise ValidationError({
- 'vc_position': "A device assigned to a virtual chassis must have its position defined."
- })
-
- def save(self, *args, **kwargs):
-
- is_new = not bool(self.pk)
-
- super().save(*args, **kwargs)
-
- # If this is a new Device, instantiate all of the related components per the DeviceType definition
- if is_new:
- ConsolePort.objects.bulk_create(
- [x.instantiate(self) for x in self.device_type.consoleport_templates.all()]
- )
- ConsoleServerPort.objects.bulk_create(
- [x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()]
- )
- PowerPort.objects.bulk_create(
- [x.instantiate(self) for x in self.device_type.powerport_templates.all()]
- )
- PowerOutlet.objects.bulk_create(
- [x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()]
- )
- Interface.objects.bulk_create(
- [x.instantiate(self) for x in self.device_type.interface_templates.all()]
- )
- RearPort.objects.bulk_create(
- [x.instantiate(self) for x in self.device_type.rearport_templates.all()]
- )
- FrontPort.objects.bulk_create(
- [x.instantiate(self) for x in self.device_type.frontport_templates.all()]
- )
- DeviceBay.objects.bulk_create(
- [x.instantiate(self) for x in self.device_type.device_bay_templates.all()]
- )
-
- # Update Site and Rack assignment for any child Devices
- devices = Device.objects.filter(parent_bay__device=self)
- for device in devices:
- device.site = self.site
- device.rack = self.rack
- device.save()
-
- def to_csv(self):
- return (
- self.name or '',
- self.device_role.name,
- self.tenant.name if self.tenant else None,
- self.device_type.manufacturer.name,
- self.device_type.model,
- self.platform.name if self.platform else None,
- self.serial,
- self.asset_tag,
- self.get_status_display(),
- self.site.name,
- self.rack.group.name if self.rack and self.rack.group else None,
- self.rack.name if self.rack else None,
- self.position,
- self.get_face_display(),
- self.comments,
- )
-
- @property
- def display_name(self):
- if self.name:
- return self.name
- elif self.virtual_chassis and self.virtual_chassis.master.name:
- return "{}:{}".format(self.virtual_chassis.master, self.vc_position)
- elif hasattr(self, 'device_type'):
- return "{}".format(self.device_type)
- return ""
-
- @property
- def identifier(self):
- """
- Return the device name if set; otherwise return the Device's primary key as {pk}
- """
- if self.name is not None:
- return self.name
- return '{{{}}}'.format(self.pk)
-
- @property
- def primary_ip(self):
- if settings.PREFER_IPV4 and self.primary_ip4:
- return self.primary_ip4
- elif self.primary_ip6:
- return self.primary_ip6
- elif self.primary_ip4:
- return self.primary_ip4
- else:
- return None
-
- def get_vc_master(self):
- """
- If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
- """
- return self.virtual_chassis.master if self.virtual_chassis else None
-
- @property
- def vc_interfaces(self):
- """
- Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
- Device belonging to the same VirtualChassis.
- """
- filter = Q(device=self)
- if self.virtual_chassis and self.virtual_chassis.master == self:
- filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
- return Interface.objects.filter(filter)
-
- def get_cables(self, pk_list=False):
- """
- Return a QuerySet or PK list matching all Cables connected to a component of this Device.
- """
- cable_pks = []
- for component_model in [
- ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
- ]:
- cable_pks += component_model.objects.filter(
- device=self, cable__isnull=False
- ).values_list('cable', flat=True)
- if pk_list:
- return cable_pks
- return Cable.objects.filter(pk__in=cable_pks)
-
- def get_children(self):
- """
- Return the set of child Devices installed in DeviceBays within this Device.
- """
- return Device.objects.filter(parent_bay__device=self.pk)
-
- def get_status_class(self):
- return self.STATUS_CLASS_MAP.get(self.status)
-
-
-#
-# Virtual chassis
-#
-
-@extras_features('custom_links', 'export_templates', 'webhooks')
-class VirtualChassis(ChangeLoggedModel):
- """
- A collection of Devices which operate with a shared control plane (e.g. a switch stack).
- """
- master = models.OneToOneField(
- to='Device',
- on_delete=models.PROTECT,
- related_name='vc_master_for'
- )
- domain = models.CharField(
- max_length=30,
- blank=True
- )
-
- tags = TaggableManager(through=TaggedItem)
-
- csv_headers = ['master', 'domain']
-
- class Meta:
- ordering = ['master']
- verbose_name_plural = 'virtual chassis'
-
- def __str__(self):
- return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
-
- def get_absolute_url(self):
- return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
-
- def clean(self):
-
- # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
- # VirtualChassis.)
- if self.pk and self.master not in self.members.all():
- raise ValidationError({
- 'master': "The selected master is not assigned to this virtual chassis."
- })
-
- def delete(self, *args, **kwargs):
-
- # Check for LAG interfaces split across member chassis
- interfaces = Interface.objects.filter(
- device__in=self.members.all(),
- lag__isnull=False
- ).exclude(
- lag__device=F('device')
- )
- if interfaces:
- raise ProtectedError(
- "Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
- "LAG".format(self),
- interfaces
- )
-
- return super().delete(*args, **kwargs)
-
- def to_csv(self):
- return (
- self.master,
- self.domain,
- )
-
-
-#
-# Power
-#
-
-@extras_features('custom_links', 'export_templates', 'webhooks')
-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']
-
- 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
- ))
-
-
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
-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.CharField(
- max_length=50,
- choices=PowerFeedStatusChoices,
- default=PowerFeedStatusChoices.STATUS_ACTIVE
- )
- type = models.CharField(
- max_length=50,
- choices=PowerFeedTypeChoices,
- default=PowerFeedTypeChoices.TYPE_PRIMARY
- )
- supply = models.CharField(
- max_length=50,
- choices=PowerFeedSupplyChoices,
- default=PowerFeedSupplyChoices.SUPPLY_AC
- )
- phase = models.CharField(
- max_length=50,
- choices=PowerFeedPhaseChoices,
- default=PowerFeedPhaseChoices.PHASE_SINGLE
- )
- voltage = models.SmallIntegerField(
- default=POWERFEED_VOLTAGE_DEFAULT,
- validators=[ExclusionValidator([0])]
- )
- amperage = models.PositiveSmallIntegerField(
- validators=[MinValueValidator(1)],
- default=POWERFEED_AMPERAGE_DEFAULT
- )
- max_utilization = models.PositiveSmallIntegerField(
- validators=[MinValueValidator(1), MaxValueValidator(100)],
- default=POWERFEED_MAX_UTILIZATION_DEFAULT,
- help_text="Maximum permissible draw (percentage)"
- )
- available_power = models.PositiveIntegerField(
- 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', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
- 'amperage', 'max_utilization', 'comments',
- ]
- clone_fields = [
- 'power_panel', 'rack', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
- 'available_power',
- ]
-
- STATUS_CLASS_MAP = {
- PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
- PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
- PowerFeedStatusChoices.STATUS_PLANNED: 'info',
- PowerFeedStatusChoices.STATUS_FAILED: 'danger',
- }
-
- TYPE_CLASS_MAP = {
- PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
- PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
- }
-
- 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.site.name,
- self.power_panel.name,
- self.rack.group.name if self.rack and self.rack.group else None,
- 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
- ))
-
- # AC voltage cannot be negative
- if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
- raise ValidationError({
- "voltage": "Voltage cannot be negative for AC supply"
- })
-
- def save(self, *args, **kwargs):
-
- # Cache the available_power property on the instance
- kva = abs(self.voltage) * self.amperage * (self.max_utilization / 100)
- if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
- self.available_power = round(kva * 1.732)
- else:
- self.available_power = round(kva)
-
- super().save(*args, **kwargs)
-
- @property
- def parent(self):
- return self.power_panel
-
- def get_type_class(self):
- return self.TYPE_CLASS_MAP.get(self.type)
-
- def get_status_class(self):
- return self.STATUS_CLASS_MAP.get(self.status)
-
-
-#
-# Cables
-#
-
-@extras_features('custom_links', 'export_templates', 'webhooks')
-class Cable(ChangeLoggedModel):
- """
- A physical connection between two endpoints.
- """
- termination_a_type = models.ForeignKey(
- to=ContentType,
- limit_choices_to=CABLE_TERMINATION_MODELS,
- on_delete=models.PROTECT,
- related_name='+'
- )
- termination_a_id = models.PositiveIntegerField()
- termination_a = GenericForeignKey(
- ct_field='termination_a_type',
- fk_field='termination_a_id'
- )
- termination_b_type = models.ForeignKey(
- to=ContentType,
- limit_choices_to=CABLE_TERMINATION_MODELS,
- on_delete=models.PROTECT,
- related_name='+'
- )
- termination_b_id = models.PositiveIntegerField()
- termination_b = GenericForeignKey(
- ct_field='termination_b_type',
- fk_field='termination_b_id'
- )
- type = models.CharField(
- max_length=50,
- choices=CableTypeChoices,
- blank=True
- )
- status = models.CharField(
- max_length=50,
- choices=CableStatusChoices,
- default=CableStatusChoices.STATUS_CONNECTED
- )
- label = models.CharField(
- max_length=100,
- blank=True
- )
- color = ColorField(
- blank=True
- )
- length = models.PositiveSmallIntegerField(
- blank=True,
- null=True
- )
- length_unit = models.CharField(
- max_length=50,
- choices=CableLengthUnitChoices,
- blank=True,
- )
- # Stores the normalized length (in meters) for database ordering
- _abs_length = models.DecimalField(
- max_digits=10,
- decimal_places=4,
- blank=True,
- null=True
- )
- # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
- # their associated Devices.
- _termination_a_device = models.ForeignKey(
- to=Device,
- on_delete=models.CASCADE,
- related_name='+',
- blank=True,
- null=True
- )
- _termination_b_device = models.ForeignKey(
- to=Device,
- on_delete=models.CASCADE,
- related_name='+',
- blank=True,
- null=True
- )
-
- csv_headers = [
- 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
- 'color', 'length', 'length_unit',
- ]
-
- STATUS_CLASS_MAP = {
- CableStatusChoices.STATUS_CONNECTED: 'success',
- CableStatusChoices.STATUS_PLANNED: 'info',
- CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
- }
-
- class Meta:
- ordering = ['pk']
- unique_together = (
- ('termination_a_type', 'termination_a_id'),
- ('termination_b_type', 'termination_b_id'),
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # A copy of the PK to be used by __str__ in case the object is deleted
- self._pk = self.pk
-
- @classmethod
- def from_db(cls, db, field_names, values):
- """
- Cache the original A and B terminations of existing Cable instances for later reference inside clean().
- """
- instance = super().from_db(db, field_names, values)
-
- instance._orig_termination_a_type_id = instance.termination_a_type_id
- instance._orig_termination_a_id = instance.termination_a_id
- instance._orig_termination_b_type_id = instance.termination_b_type_id
- instance._orig_termination_b_id = instance.termination_b_id
-
- return instance
-
- def __str__(self):
- return self.label or '#{}'.format(self._pk)
-
- def get_absolute_url(self):
- return reverse('dcim:cable', args=[self.pk])
-
- def clean(self):
- from circuits.models import CircuitTermination
-
- # Validate that termination A exists
- if not hasattr(self, 'termination_a_type'):
- raise ValidationError('Termination A type has not been specified')
- try:
- self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
- except ObjectDoesNotExist:
- raise ValidationError({
- 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
- })
-
- # Validate that termination B exists
- if not hasattr(self, 'termination_b_type'):
- raise ValidationError('Termination B type has not been specified')
- try:
- self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
- except ObjectDoesNotExist:
- raise ValidationError({
- 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
- })
-
- # If editing an existing Cable instance, check that neither termination has been modified.
- if self.pk:
- err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
- if (
- self.termination_a_type_id != self._orig_termination_a_type_id or
- self.termination_a_id != self._orig_termination_a_id
- ):
- raise ValidationError({
- 'termination_a': err_msg
- })
- if (
- self.termination_b_type_id != self._orig_termination_b_type_id or
- self.termination_b_id != self._orig_termination_b_id
- ):
- raise ValidationError({
- 'termination_b': err_msg
- })
-
- type_a = self.termination_a_type.model
- type_b = self.termination_b_type.model
-
- # Validate interface types
- if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
- raise ValidationError({
- 'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
- self.termination_a.get_type_display()
- )
- })
- if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
- raise ValidationError({
- 'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
- self.termination_b.get_type_display()
- )
- })
-
- # Check that termination types are compatible
- if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
- raise ValidationError(
- f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
- )
-
- # Check that a RearPort with multiple positions isn't connected to an endpoint
- # or a RearPort with a different number of positions.
- for term_a, term_b in [
- (self.termination_a, self.termination_b),
- (self.termination_b, self.termination_a)
- ]:
- if isinstance(term_a, RearPort) and term_a.positions > 1:
- if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
- raise ValidationError(
- "Rear ports with multiple positions may only be connected to other pass-through ports"
- )
- if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
- raise ValidationError(
- f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
- f"{term_b} of {term_b.device} has {term_b.positions}. "
- f"Both terminations must have the same number of positions."
- )
-
- # A termination point cannot be connected to itself
- if self.termination_a == self.termination_b:
- raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
-
- # A front port cannot be connected to its corresponding rear port
- if (
- type_a in ['frontport', 'rearport'] and
- type_b in ['frontport', 'rearport'] and
- (
- getattr(self.termination_a, 'rear_port', None) == self.termination_b or
- getattr(self.termination_b, 'rear_port', None) == self.termination_a
- )
- ):
- raise ValidationError("A front port cannot be connected to it corresponding rear port")
-
- # Check for an existing Cable connected to either termination object
- if self.termination_a.cable not in (None, self):
- raise ValidationError("{} already has a cable attached (#{})".format(
- self.termination_a, self.termination_a.cable_id
- ))
- if self.termination_b.cable not in (None, self):
- raise ValidationError("{} already has a cable attached (#{})".format(
- self.termination_b, self.termination_b.cable_id
- ))
-
- # Validate length and length_unit
- if self.length is not None and not self.length_unit:
- raise ValidationError("Must specify a unit when setting a cable length")
- elif self.length is None:
- self.length_unit = ''
-
- def save(self, *args, **kwargs):
-
- # Store the given length (if any) in meters for use in database ordering
- if self.length and self.length_unit:
- self._abs_length = to_meters(self.length, self.length_unit)
- else:
- self._abs_length = None
-
- # Store the parent Device for the A and B terminations (if applicable) to enable filtering
- if hasattr(self.termination_a, 'device'):
- self._termination_a_device = self.termination_a.device
- if hasattr(self.termination_b, 'device'):
- self._termination_b_device = self.termination_b.device
-
- super().save(*args, **kwargs)
-
- # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
- self._pk = self.pk
-
- def to_csv(self):
- return (
- '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
- self.termination_a_id,
- '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
- self.termination_b_id,
- self.get_type_display(),
- self.get_status_display(),
- self.label,
- self.color,
- self.length,
- self.length_unit,
- )
-
- def get_status_class(self):
- return self.STATUS_CLASS_MAP.get(self.status)
-
- 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]
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 164d37d77..492fe3762 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -6,6 +6,7 @@ from dcim.choices import *
from dcim.constants import *
from extras.models import ObjectChange
from utilities.fields import NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
from .device_components import (
@@ -26,10 +27,39 @@ __all__ = (
class ComponentTemplateModel(models.Model):
+ device_type = models.ForeignKey(
+ to='dcim.DeviceType',
+ on_delete=models.CASCADE,
+ related_name='%(class)ss'
+ )
+ name = models.CharField(
+ max_length=64
+ )
+ _name = NaturalOrderingField(
+ target_field='name',
+ max_length=100,
+ blank=True
+ )
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="Physical label"
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+
+ objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
+ def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
+ return self.name
+
def instantiate(self, device):
"""
Instantiate a new component on the specified Device.
@@ -56,19 +86,6 @@ class ConsolePortTemplate(ComponentTemplateModel):
"""
A template for a ConsolePort to be created for a new Device.
"""
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.CASCADE,
- related_name='consoleport_templates'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -79,13 +96,11 @@ class ConsolePortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
- def __str__(self):
- return self.name
-
def instantiate(self, device):
return ConsolePort(
device=device,
name=self.name,
+ label=self.label,
type=self.type
)
@@ -94,19 +109,6 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
"""
A template for a ConsoleServerPort to be created for a new Device.
"""
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.CASCADE,
- related_name='consoleserverport_templates'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -117,13 +119,11 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
- def __str__(self):
- return self.name
-
def instantiate(self, device):
return ConsoleServerPort(
device=device,
name=self.name,
+ label=self.label,
type=self.type
)
@@ -132,19 +132,6 @@ class PowerPortTemplate(ComponentTemplateModel):
"""
A template for a PowerPort to be created for a new Device.
"""
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.CASCADE,
- related_name='powerport_templates'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -167,13 +154,11 @@ class PowerPortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
- def __str__(self):
- return self.name
-
def instantiate(self, device):
return PowerPort(
device=device,
name=self.name,
+ label=self.label,
type=self.type,
maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw
@@ -184,19 +169,6 @@ class PowerOutletTemplate(ComponentTemplateModel):
"""
A template for a PowerOutlet to be created for a new Device.
"""
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.CASCADE,
- related_name='poweroutlet_templates'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
@@ -220,9 +192,6 @@ class PowerOutletTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
- def __str__(self):
- return self.name
-
def clean(self):
# Validate power port assignment
@@ -239,6 +208,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
return PowerOutlet(
device=device,
name=self.name,
+ label=self.label,
type=self.type,
power_port=power_port,
feed_leg=self.feed_leg
@@ -249,14 +219,7 @@ class InterfaceTemplate(ComponentTemplateModel):
"""
A template for a physical data interface on a new Device.
"""
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.CASCADE,
- related_name='interface_templates'
- )
- name = models.CharField(
- max_length=64
- )
+ # Override ComponentTemplateModel._name to specify naturalize_interface function
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
@@ -276,13 +239,11 @@ class InterfaceTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
- def __str__(self):
- return self.name
-
def instantiate(self, device):
return Interface(
device=device,
name=self.name,
+ label=self.label,
type=self.type,
mgmt_only=self.mgmt_only
)
@@ -292,19 +253,6 @@ class FrontPortTemplate(ComponentTemplateModel):
"""
Template for a pass-through port on the front of a new Device.
"""
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.CASCADE,
- related_name='frontport_templates'
- )
- name = models.CharField(
- max_length=64
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -326,9 +274,6 @@ class FrontPortTemplate(ComponentTemplateModel):
('rear_port', 'rear_port_position'),
)
- def __str__(self):
- return self.name
-
def clean(self):
# Validate rear port assignment
@@ -353,6 +298,7 @@ class FrontPortTemplate(ComponentTemplateModel):
return FrontPort(
device=device,
name=self.name,
+ label=self.label,
type=self.type,
rear_port=rear_port,
rear_port_position=self.rear_port_position
@@ -363,19 +309,6 @@ class RearPortTemplate(ComponentTemplateModel):
"""
Template for a pass-through port on the rear of a new Device.
"""
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.CASCADE,
- related_name='rearport_templates'
- )
- name = models.CharField(
- max_length=64
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -389,13 +322,11 @@ class RearPortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
- def __str__(self):
- return self.name
-
def instantiate(self, device):
return RearPort(
device=device,
name=self.name,
+ label=self.label,
type=self.type,
positions=self.positions
)
@@ -405,29 +336,13 @@ class DeviceBayTemplate(ComponentTemplateModel):
"""
A template for a DeviceBay to be created for a new parent Device.
"""
- device_type = models.ForeignKey(
- to='dcim.DeviceType',
- on_delete=models.CASCADE,
- related_name='device_bay_templates'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
-
class Meta:
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
- def __str__(self):
- return self.name
-
def instantiate(self, device):
return DeviceBay(
device=device,
- name=self.name
+ name=self.name,
+ label=self.label
)
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 0143b39d9..92b0605e9 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -16,12 +16,13 @@ from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
+from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar
from utilities.utils import serialize_object
-from virtualization.choices import VMInterfaceTypeChoices
__all__ = (
+ 'BaseInterface',
'CableTermination',
'ConsolePort',
'ConsoleServerPort',
@@ -36,30 +37,51 @@ __all__ = (
class ComponentModel(models.Model):
+ device = models.ForeignKey(
+ to='dcim.Device',
+ on_delete=models.CASCADE,
+ related_name='%(class)ss'
+ )
+ name = models.CharField(
+ max_length=64
+ )
+ _name = NaturalOrderingField(
+ target_field='name',
+ max_length=100,
+ blank=True
+ )
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="Physical label"
+ )
description = models.CharField(
max_length=200,
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
class Meta:
abstract = True
def __str__(self):
- return getattr(self, 'name')
+ if self.label:
+ return f"{self.name} ({self.label})"
+ return self.name
def to_objectchange(self, action):
- # Annotate the parent Device/VM
+ # Annotate the parent Device
try:
- parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
+ device = self.device
except ObjectDoesNotExist:
- # The parent device/VM has already been deleted
- parent = None
-
+ # The parent Device has already been deleted
+ device = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
- related_object=parent,
+ related_object=device,
object_data=serialize_object(self)
)
@@ -235,19 +257,6 @@ class ConsolePort(CableTermination, ComponentModel):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='consoleports'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -261,25 +270,27 @@ class ConsolePort(CableTermination, ComponentModel):
blank=True,
null=True
)
- connection_status = models.NullBooleanField(
+ connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
- blank=True
+ blank=True,
+ null=True
)
tags = TaggableManager(through=TaggedItem)
- csv_headers = ['device', 'name', 'type', 'description']
+ csv_headers = ['device', 'name', 'label', 'type', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
- return self.device.get_absolute_url()
+ return reverse('dcim:consoleport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
+ self.label,
self.type,
self.description,
)
@@ -294,44 +305,33 @@ class ConsoleServerPort(CableTermination, ComponentModel):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='consoleserverports'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
blank=True,
help_text='Physical port type'
)
- connection_status = models.NullBooleanField(
+ connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
- blank=True
+ blank=True,
+ null=True
)
tags = TaggableManager(through=TaggedItem)
- csv_headers = ['device', 'name', 'type', 'description']
+ csv_headers = ['device', 'name', 'label', 'type', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
- return self.device.get_absolute_url()
+ return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
+ self.label,
self.type,
self.description,
)
@@ -346,19 +346,6 @@ class PowerPort(CableTermination, ComponentModel):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='powerports'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -391,25 +378,27 @@ class PowerPort(CableTermination, ComponentModel):
blank=True,
null=True
)
- connection_status = models.NullBooleanField(
+ connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
- blank=True
+ blank=True,
+ null=True
)
tags = TaggableManager(through=TaggedItem)
- csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
+ csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
- return self.device.get_absolute_url()
+ return reverse('dcim:powerport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
+ self.label,
self.get_type_display(),
self.maximum_draw,
self.allocated_draw,
@@ -506,19 +495,6 @@ class PowerOutlet(CableTermination, ComponentModel):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='poweroutlets'
- )
- name = models.CharField(
- max_length=50
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
@@ -538,25 +514,27 @@ class PowerOutlet(CableTermination, ComponentModel):
blank=True,
help_text="Phase (for three-phase feeds)"
)
- connection_status = models.NullBooleanField(
+ connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
- blank=True
+ blank=True,
+ null=True
)
tags = TaggableManager(through=TaggedItem)
- csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
+ csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
- return self.device.get_absolute_url()
+ return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
+ self.label,
self.get_type_display(),
self.power_port.name if self.power_port else None,
self.get_feed_leg_display(),
@@ -576,29 +554,40 @@ class PowerOutlet(CableTermination, ComponentModel):
# Interfaces
#
+class BaseInterface(models.Model):
+ """
+ Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
+ """
+ enabled = models.BooleanField(
+ default=True
+ )
+ mac_address = MACAddressField(
+ null=True,
+ blank=True,
+ verbose_name='MAC Address'
+ )
+ mtu = models.PositiveIntegerField(
+ blank=True,
+ null=True,
+ validators=[MinValueValidator(1), MaxValueValidator(65536)],
+ verbose_name='MTU'
+ )
+ mode = models.CharField(
+ max_length=50,
+ choices=InterfaceModeChoices,
+ blank=True
+ )
+
+ class Meta:
+ abstract = True
+
+
@extras_features('graphs', 'export_templates', 'webhooks')
-class Interface(CableTermination, ComponentModel):
+class Interface(CableTermination, ComponentModel, BaseInterface):
"""
- A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
- Interface.
+ A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
- device = models.ForeignKey(
- to='Device',
- on_delete=models.CASCADE,
- related_name='interfaces',
- null=True,
- blank=True
- )
- virtual_machine = models.ForeignKey(
- to='virtualization.VirtualMachine',
- on_delete=models.CASCADE,
- related_name='interfaces',
- null=True,
- blank=True
- )
- name = models.CharField(
- max_length=64
- )
+ # Override ComponentModel._name to specify naturalize_interface function
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
@@ -619,9 +608,10 @@ class Interface(CableTermination, ComponentModel):
blank=True,
null=True
)
- connection_status = models.NullBooleanField(
+ connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
- blank=True
+ blank=True,
+ null=True
)
lag = models.ForeignKey(
to='self',
@@ -635,30 +625,11 @@ class Interface(CableTermination, ComponentModel):
max_length=50,
choices=InterfaceTypeChoices
)
- enabled = models.BooleanField(
- default=True
- )
- mac_address = MACAddressField(
- null=True,
- blank=True,
- verbose_name='MAC Address'
- )
- mtu = models.PositiveIntegerField(
- blank=True,
- null=True,
- validators=[MinValueValidator(1), MaxValueValidator(65536)],
- verbose_name='MTU'
- )
mgmt_only = models.BooleanField(
default=False,
verbose_name='OOB Management',
help_text='This interface is used only for out-of-band management'
)
- mode = models.CharField(
- max_length=50,
- choices=InterfaceModeChoices,
- blank=True
- )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@@ -673,15 +644,19 @@ class Interface(CableTermination, ComponentModel):
blank=True,
verbose_name='Tagged VLANs'
)
+ ip_addresses = GenericRelation(
+ to='ipam.IPAddress',
+ content_type_field='assigned_object_type',
+ object_id_field='assigned_object_id',
+ related_query_name='interface'
+ )
tags = TaggableManager(through=TaggedItem)
csv_headers = [
- 'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
- 'description', 'mode',
+ 'device', 'name', 'label', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode',
]
class Meta:
- # TODO: ordering and unique_together should include virtual_machine
ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name')
@@ -691,8 +666,8 @@ class Interface(CableTermination, ComponentModel):
def to_csv(self):
return (
self.device.identifier if self.device else None,
- self.virtual_machine.name if self.virtual_machine else None,
self.name,
+ self.label,
self.lag.name if self.lag else None,
self.get_type_display(),
self.enabled,
@@ -705,18 +680,6 @@ class Interface(CableTermination, ComponentModel):
def clean(self):
- # An Interface must belong to a Device *or* to a VirtualMachine
- if self.device and self.virtual_machine:
- raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
- if not self.device and not self.virtual_machine:
- raise ValidationError("An interface must belong to either a device or a virtual machine.")
-
- # VM interfaces must be virtual
- if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values():
- raise ValidationError({
- 'type': "Invalid interface type for a virtual machine: {}".format(self.type)
- })
-
# Virtual interfaces cannot be connected
if self.type in NONCONNECTABLE_IFACE_TYPES and (
self.cable or getattr(self, 'circuit_termination', False)
@@ -726,13 +689,17 @@ class Interface(CableTermination, ComponentModel):
"Disconnect the interface or choose a suitable type."
})
- # An interface's LAG must belong to the same device (or VC master)
- if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
- raise ValidationError({
- 'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
- self.lag.name, self.lag.device.name
- )
- })
+ # An interface's LAG must belong to the same device or virtual chassis
+ if self.lag and self.lag.device != self.device:
+ if self.device.virtual_chassis is None:
+ raise ValidationError({
+ 'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
+ })
+ elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
+ raise ValidationError({
+ 'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
+ f"of virtual chassis {self.device.virtual_chassis}."
+ })
# A virtual interface cannot have a parent LAG
if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
@@ -752,7 +719,7 @@ class Interface(CableTermination, ComponentModel):
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
- "device/VM, or it must be global".format(self.untagged_vlan)
+ "device, or it must be global".format(self.untagged_vlan)
})
def save(self, *args, **kwargs):
@@ -767,21 +734,6 @@ class Interface(CableTermination, ComponentModel):
return super().save(*args, **kwargs)
- def to_objectchange(self, action):
- # Annotate the parent Device/VM
- try:
- parent_obj = self.device or self.virtual_machine
- except ObjectDoesNotExist:
- parent_obj = None
-
- return ObjectChange(
- changed_object=self,
- object_repr=str(self),
- action=action,
- related_object=parent_obj,
- object_data=serialize_object(self)
- )
-
@property
def connected_endpoint(self):
"""
@@ -820,7 +772,7 @@ class Interface(CableTermination, ComponentModel):
@property
def parent(self):
- return self.device or self.virtual_machine
+ return self.device
@property
def is_connectable(self):
@@ -852,19 +804,6 @@ class FrontPort(CableTermination, ComponentModel):
"""
A pass-through port on the front of a Device.
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='frontports'
- )
- name = models.CharField(
- max_length=64
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -880,7 +819,7 @@ class FrontPort(CableTermination, ComponentModel):
)
tags = TaggableManager(through=TaggedItem)
- csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
+ csv_headers = ['device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description']
class Meta:
ordering = ('device', '_name')
@@ -889,10 +828,14 @@ class FrontPort(CableTermination, ComponentModel):
('rear_port', 'rear_port_position'),
)
+ def get_absolute_url(self):
+ return reverse('dcim:frontport', kwargs={'pk': self.pk})
+
def to_csv(self):
return (
self.device.identifier,
self.name,
+ self.label,
self.get_type_display(),
self.rear_port.name,
self.rear_port_position,
@@ -921,19 +864,6 @@ class RearPort(CableTermination, ComponentModel):
"""
A pass-through port on the rear of a Device.
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='rearports'
- )
- name = models.CharField(
- max_length=64
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -944,16 +874,20 @@ class RearPort(CableTermination, ComponentModel):
)
tags = TaggableManager(through=TaggedItem)
- csv_headers = ['device', 'name', 'type', 'positions', 'description']
+ csv_headers = ['device', 'name', 'label', 'type', 'positions', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
+ def get_absolute_url(self):
+ return reverse('dcim:rearport', kwargs={'pk': self.pk})
+
def to_csv(self):
return (
self.device.identifier,
self.name,
+ self.label,
self.get_type_display(),
self.positions,
self.description,
@@ -969,20 +903,6 @@ class DeviceBay(ComponentModel):
"""
An empty space within a Device which can house a child device
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='device_bays'
- )
- name = models.CharField(
- max_length=50,
- verbose_name='Name'
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
installed_device = models.OneToOneField(
to='dcim.Device',
on_delete=models.SET_NULL,
@@ -992,19 +912,20 @@ class DeviceBay(ComponentModel):
)
tags = TaggableManager(through=TaggedItem)
- csv_headers = ['device', 'name', 'installed_device', 'description']
+ csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
- return self.device.get_absolute_url()
+ return reverse('dcim:devicebay', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
+ self.label,
self.installed_device.identifier if self.installed_device else None,
self.description,
)
@@ -1042,11 +963,6 @@ class InventoryItem(ComponentModel):
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes.
"""
- device = models.ForeignKey(
- to='dcim.Device',
- on_delete=models.CASCADE,
- related_name='inventory_items'
- )
parent = models.ForeignKey(
to='self',
on_delete=models.CASCADE,
@@ -1054,15 +970,6 @@ class InventoryItem(ComponentModel):
blank=True,
null=True
)
- name = models.CharField(
- max_length=50,
- verbose_name='Name'
- )
- _name = NaturalOrderingField(
- target_field='name',
- max_length=100,
- blank=True
- )
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
@@ -1097,23 +1004,21 @@ class InventoryItem(ComponentModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = [
- 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
+ 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
]
class Meta:
ordering = ('device__id', 'parent__id', '_name')
unique_together = ('device', 'parent', 'name')
- def __str__(self):
- return self.name
-
def get_absolute_url(self):
- return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk})
+ return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.name or '{{{}}}'.format(self.device.pk),
self.name,
+ self.label,
self.manufacturer.name if self.manufacturer else None,
self.part_id,
self.serial,
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
new file mode 100644
index 000000000..4189e0446
--- /dev/null
+++ b/netbox/dcim/models/devices.py
@@ -0,0 +1,1247 @@
+from collections import OrderedDict
+
+import yaml
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.db.models import F, ProtectedError
+from django.urls import reverse
+from django.utils.safestring import mark_safe
+from taggit.managers import TaggableManager
+
+from dcim.choices import *
+from dcim.constants import *
+from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem
+from extras.utils import extras_features
+from utilities.choices import ColorChoices
+from utilities.fields import ColorField, NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
+from utilities.utils import to_meters
+from .device_components import *
+
+
+__all__ = (
+ 'Cable',
+ 'Device',
+ 'DeviceRole',
+ 'DeviceType',
+ 'Manufacturer',
+ 'Platform',
+ 'VirtualChassis',
+)
+
+
+#
+# Device Types
+#
+
+@extras_features('export_templates', 'webhooks')
+class Manufacturer(ChangeLoggedModel):
+ """
+ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
+ """
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = ['name', 'slug', 'description']
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
+
+ def to_csv(self):
+ return (
+ self.name,
+ self.slug,
+ self.description
+ )
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class DeviceType(ChangeLoggedModel, CustomFieldModel):
+ """
+ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
+ well as high-level functional role(s).
+
+ Each DeviceType can have an arbitrary number of component templates assigned to it, which define console, power, and
+ interface objects. For example, a Juniper EX4300-48T DeviceType would have:
+
+ * 1 ConsolePortTemplate
+ * 2 PowerPortTemplates
+ * 48 InterfaceTemplates
+
+ When a new Device of this type is created, the appropriate console, power, and interface objects (as defined by the
+ DeviceType) are automatically created as well.
+ """
+ manufacturer = models.ForeignKey(
+ to='dcim.Manufacturer',
+ on_delete=models.PROTECT,
+ related_name='device_types'
+ )
+ model = models.CharField(
+ max_length=50
+ )
+ slug = models.SlugField()
+ part_number = models.CharField(
+ max_length=50,
+ blank=True,
+ help_text='Discrete part number (optional)'
+ )
+ u_height = models.PositiveSmallIntegerField(
+ default=1,
+ verbose_name='Height (U)'
+ )
+ is_full_depth = models.BooleanField(
+ default=True,
+ verbose_name='Is full depth',
+ help_text='Device consumes both front and rear rack faces'
+ )
+ subdevice_role = models.CharField(
+ max_length=50,
+ choices=SubdeviceRoleChoices,
+ blank=True,
+ verbose_name='Parent/child status',
+ help_text='Parent devices house child devices in device bays. Leave blank '
+ 'if this device type is neither a parent nor a child.'
+ )
+ front_image = models.ImageField(
+ upload_to='devicetype-images',
+ blank=True
+ )
+ rear_image = models.ImageField(
+ upload_to='devicetype-images',
+ blank=True
+ )
+ 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)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ clone_fields = [
+ 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
+ ]
+
+ class Meta:
+ ordering = ['manufacturer', 'model']
+ unique_together = [
+ ['manufacturer', 'model'],
+ ['manufacturer', 'slug'],
+ ]
+
+ def __str__(self):
+ return self.model
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Save a copy of u_height for validation in clean()
+ self._original_u_height = self.u_height
+
+ # Save references to the original front/rear images
+ self._original_front_image = self.front_image
+ self._original_rear_image = self.rear_image
+
+ def get_absolute_url(self):
+ return reverse('dcim:devicetype', args=[self.pk])
+
+ def to_yaml(self):
+ data = OrderedDict((
+ ('manufacturer', self.manufacturer.name),
+ ('model', self.model),
+ ('slug', self.slug),
+ ('part_number', self.part_number),
+ ('u_height', self.u_height),
+ ('is_full_depth', self.is_full_depth),
+ ('subdevice_role', self.subdevice_role),
+ ('comments', self.comments),
+ ))
+
+ # Component templates
+ if self.consoleporttemplates.exists():
+ data['console-ports'] = [
+ {
+ 'name': c.name,
+ 'type': c.type,
+ }
+ for c in self.consoleporttemplates.all()
+ ]
+ if self.consoleserverporttemplates.exists():
+ data['console-server-ports'] = [
+ {
+ 'name': c.name,
+ 'type': c.type,
+ }
+ for c in self.consoleserverporttemplates.all()
+ ]
+ if self.powerporttemplates.exists():
+ data['power-ports'] = [
+ {
+ 'name': c.name,
+ 'type': c.type,
+ 'maximum_draw': c.maximum_draw,
+ 'allocated_draw': c.allocated_draw,
+ }
+ for c in self.powerporttemplates.all()
+ ]
+ if self.poweroutlettemplates.exists():
+ data['power-outlets'] = [
+ {
+ 'name': c.name,
+ 'type': c.type,
+ 'power_port': c.power_port.name if c.power_port else None,
+ 'feed_leg': c.feed_leg,
+ }
+ for c in self.poweroutlettemplates.all()
+ ]
+ if self.interfacetemplates.exists():
+ data['interfaces'] = [
+ {
+ 'name': c.name,
+ 'type': c.type,
+ 'mgmt_only': c.mgmt_only,
+ }
+ for c in self.interfacetemplates.all()
+ ]
+ if self.frontporttemplates.exists():
+ data['front-ports'] = [
+ {
+ 'name': c.name,
+ 'type': c.type,
+ 'rear_port': c.rear_port.name,
+ 'rear_port_position': c.rear_port_position,
+ }
+ for c in self.frontporttemplates.all()
+ ]
+ if self.rearporttemplates.exists():
+ data['rear-ports'] = [
+ {
+ 'name': c.name,
+ 'type': c.type,
+ 'positions': c.positions,
+ }
+ for c in self.rearporttemplates.all()
+ ]
+ if self.devicebaytemplates.exists():
+ data['device-bays'] = [
+ {
+ 'name': c.name,
+ }
+ for c in self.devicebaytemplates.all()
+ ]
+
+ return yaml.dump(dict(data), sort_keys=False)
+
+ def clean(self):
+
+ # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
+ # room to expand within their racks. This validation will impose a very high performance penalty when there are
+ # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
+ if self.pk and self.u_height > self._original_u_height:
+ for d in Device.objects.filter(device_type=self, position__isnull=False):
+ face_required = None if self.is_full_depth else d.face
+ u_available = d.rack.get_available_units(
+ u_height=self.u_height,
+ rack_face=face_required,
+ exclude=[d.pk]
+ )
+ if d.position not in u_available:
+ raise ValidationError({
+ 'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
+ "{}U".format(d, d.rack, self.u_height)
+ })
+
+ # If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
+ elif self.pk and self._original_u_height > 0 and self.u_height == 0:
+ racked_instance_count = Device.objects.filter(
+ device_type=self,
+ position__isnull=False
+ ).count()
+ if racked_instance_count:
+ url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
+ raise ValidationError({
+ 'u_height': mark_safe(
+ f'Unable to set 0U height: Found {racked_instance_count} instances already '
+ f'mounted within racks.'
+ )
+ })
+
+ if (
+ self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
+ ) and self.devicebaytemplates.count():
+ raise ValidationError({
+ 'subdevice_role': "Must delete all device bay templates associated with this device before "
+ "declassifying it as a parent device."
+ })
+
+ if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
+ raise ValidationError({
+ 'u_height': "Child device types must be 0U."
+ })
+
+ def save(self, *args, **kwargs):
+ ret = super().save(*args, **kwargs)
+
+ # Delete any previously uploaded image files that are no longer in use
+ if self.front_image != self._original_front_image:
+ self._original_front_image.delete(save=False)
+ if self.rear_image != self._original_rear_image:
+ self._original_rear_image.delete(save=False)
+
+ return ret
+
+ def delete(self, *args, **kwargs):
+ super().delete(*args, **kwargs)
+
+ # Delete any uploaded image files
+ if self.front_image:
+ self.front_image.delete(save=False)
+ if self.rear_image:
+ self.rear_image.delete(save=False)
+
+ @property
+ def display_name(self):
+ return f'{self.manufacturer.name} {self.model}'
+
+ @property
+ def is_parent_device(self):
+ return self.subdevice_role == SubdeviceRoleChoices.ROLE_PARENT
+
+ @property
+ def is_child_device(self):
+ return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
+
+
+#
+# Devices
+#
+
+class DeviceRole(ChangeLoggedModel):
+ """
+ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
+ color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
+ virtual machines as well.
+ """
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ color = ColorField(
+ default=ColorChoices.COLOR_GREY
+ )
+ vm_role = models.BooleanField(
+ default=True,
+ verbose_name='VM Role',
+ help_text='Virtual machines may be assigned to this role'
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True,
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def to_csv(self):
+ return (
+ self.name,
+ self.slug,
+ self.color,
+ self.vm_role,
+ self.description,
+ )
+
+
+class Platform(ChangeLoggedModel):
+ """
+ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
+ NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
+ specifying a NAPALM driver.
+ """
+ name = models.CharField(
+ max_length=100,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True,
+ max_length=100
+ )
+ manufacturer = models.ForeignKey(
+ to='dcim.Manufacturer',
+ on_delete=models.PROTECT,
+ related_name='platforms',
+ blank=True,
+ null=True,
+ help_text='Optionally limit this platform to devices of a certain manufacturer'
+ )
+ napalm_driver = models.CharField(
+ max_length=50,
+ blank=True,
+ verbose_name='NAPALM driver',
+ help_text='The name of the NAPALM driver to use when interacting with devices'
+ )
+ napalm_args = models.JSONField(
+ blank=True,
+ null=True,
+ verbose_name='NAPALM arguments',
+ help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)'
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
+
+ def to_csv(self):
+ return (
+ self.name,
+ self.slug,
+ self.manufacturer.name if self.manufacturer else None,
+ self.napalm_driver,
+ self.napalm_args,
+ self.description,
+ )
+
+
+@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
+class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
+ """
+ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
+ DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
+
+ Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a
+ particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units).
+
+ When a new Device is created, console/power/interface/device bay components are created along with it as dictated
+ by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
+ creation of a Device.
+ """
+ device_type = models.ForeignKey(
+ to='dcim.DeviceType',
+ on_delete=models.PROTECT,
+ related_name='instances'
+ )
+ device_role = models.ForeignKey(
+ to='dcim.DeviceRole',
+ on_delete=models.PROTECT,
+ related_name='devices'
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='devices',
+ blank=True,
+ null=True
+ )
+ platform = models.ForeignKey(
+ to='dcim.Platform',
+ on_delete=models.SET_NULL,
+ related_name='devices',
+ blank=True,
+ null=True
+ )
+ name = models.CharField(
+ max_length=64,
+ blank=True,
+ null=True
+ )
+ _name = NaturalOrderingField(
+ target_field='name',
+ max_length=100,
+ blank=True,
+ null=True
+ )
+ serial = models.CharField(
+ max_length=50,
+ blank=True,
+ verbose_name='Serial number'
+ )
+ asset_tag = models.CharField(
+ max_length=50,
+ blank=True,
+ null=True,
+ unique=True,
+ verbose_name='Asset tag',
+ help_text='A unique tag used to identify this device'
+ )
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='devices'
+ )
+ rack = models.ForeignKey(
+ to='dcim.Rack',
+ on_delete=models.PROTECT,
+ related_name='devices',
+ blank=True,
+ null=True
+ )
+ position = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ validators=[MinValueValidator(1)],
+ verbose_name='Position (U)',
+ help_text='The lowest-numbered unit occupied by the device'
+ )
+ face = models.CharField(
+ max_length=50,
+ blank=True,
+ choices=DeviceFaceChoices,
+ verbose_name='Rack face'
+ )
+ status = models.CharField(
+ max_length=50,
+ choices=DeviceStatusChoices,
+ default=DeviceStatusChoices.STATUS_ACTIVE
+ )
+ primary_ip4 = models.OneToOneField(
+ to='ipam.IPAddress',
+ on_delete=models.SET_NULL,
+ related_name='primary_ip4_for',
+ blank=True,
+ null=True,
+ verbose_name='Primary IPv4'
+ )
+ primary_ip6 = models.OneToOneField(
+ to='ipam.IPAddress',
+ on_delete=models.SET_NULL,
+ related_name='primary_ip6_for',
+ blank=True,
+ null=True,
+ verbose_name='Primary IPv6'
+ )
+ cluster = models.ForeignKey(
+ to='virtualization.Cluster',
+ on_delete=models.SET_NULL,
+ related_name='devices',
+ blank=True,
+ null=True
+ )
+ virtual_chassis = models.ForeignKey(
+ to='VirtualChassis',
+ on_delete=models.SET_NULL,
+ related_name='members',
+ blank=True,
+ null=True
+ )
+ vc_position = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ validators=[MaxValueValidator(255)]
+ )
+ vc_priority = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ validators=[MaxValueValidator(255)]
+ )
+ comments = models.TextField(
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
+ images = GenericRelation(
+ to='extras.ImageAttachment'
+ )
+ tags = TaggableManager(through=TaggedItem)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = [
+ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
+ 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
+ ]
+ clone_fields = [
+ 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster',
+ ]
+
+ STATUS_CLASS_MAP = {
+ DeviceStatusChoices.STATUS_OFFLINE: 'warning',
+ DeviceStatusChoices.STATUS_ACTIVE: 'success',
+ DeviceStatusChoices.STATUS_PLANNED: 'info',
+ DeviceStatusChoices.STATUS_STAGED: 'primary',
+ DeviceStatusChoices.STATUS_FAILED: 'danger',
+ DeviceStatusChoices.STATUS_INVENTORY: 'default',
+ DeviceStatusChoices.STATUS_DECOMMISSIONING: 'warning',
+ }
+
+ class Meta:
+ ordering = ('_name', 'pk') # Name may be null
+ unique_together = (
+ ('site', 'tenant', 'name'), # See validate_unique below
+ ('rack', 'position', 'face'),
+ ('virtual_chassis', 'vc_position'),
+ )
+
+ def __str__(self):
+ return self.display_name or super().__str__()
+
+ def get_absolute_url(self):
+ return reverse('dcim:device', args=[self.pk])
+
+ def validate_unique(self, exclude=None):
+
+ # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
+ # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
+ # of the uniqueness constraint without manual intervention.
+ if self.name and self.tenant is None:
+ if Device.objects.exclude(pk=self.pk).filter(
+ name=self.name,
+ site=self.site,
+ tenant__isnull=True
+ ):
+ raise ValidationError({
+ 'name': 'A device with this name already exists.'
+ })
+
+ super().validate_unique(exclude)
+
+ def clean(self):
+
+ super().clean()
+
+ # Validate site/rack combination
+ if self.rack and self.site != self.rack.site:
+ raise ValidationError({
+ 'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
+ })
+
+ if self.rack is None:
+ if self.face:
+ raise ValidationError({
+ 'face': "Cannot select a rack face without assigning a rack.",
+ })
+ if self.position:
+ raise ValidationError({
+ 'face': "Cannot select a rack position without assigning a rack.",
+ })
+
+ # Validate position/face combination
+ if self.position and not self.face:
+ raise ValidationError({
+ 'face': "Must specify rack face when defining rack position.",
+ })
+
+ # Prevent 0U devices from being assigned to a specific position
+ if self.position and self.device_type.u_height == 0:
+ raise ValidationError({
+ 'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type)
+ })
+
+ if self.rack:
+
+ try:
+ # Child devices cannot be assigned to a rack face/unit
+ if self.device_type.is_child_device and self.face:
+ raise ValidationError({
+ 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
+ "parent device."
+ })
+ if self.device_type.is_child_device and self.position:
+ raise ValidationError({
+ 'position': "Child device types cannot be assigned to a rack position. This is an attribute of "
+ "the parent device."
+ })
+
+ # Validate rack space
+ rack_face = self.face if not self.device_type.is_full_depth else None
+ exclude_list = [self.pk] if self.pk else []
+ available_units = self.rack.get_available_units(
+ u_height=self.device_type.u_height, rack_face=rack_face, exclude=exclude_list
+ )
+ if self.position and self.position not in available_units:
+ raise ValidationError({
+ 'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
+ "{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
+ })
+
+ except DeviceType.DoesNotExist:
+ pass
+
+ # Validate primary IP addresses
+ vc_interfaces = self.vc_interfaces.all()
+ if self.primary_ip4:
+ if self.primary_ip4.family != 4:
+ raise ValidationError({
+ 'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
+ })
+ if self.primary_ip4.assigned_object in vc_interfaces:
+ pass
+ elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.assigned_object in vc_interfaces:
+ pass
+ else:
+ raise ValidationError({
+ 'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
+ })
+ if self.primary_ip6:
+ if self.primary_ip6.family != 6:
+ raise ValidationError({
+ 'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
+ })
+ if self.primary_ip6.assigned_object in vc_interfaces:
+ pass
+ elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.assigned_object in vc_interfaces:
+ pass
+ else:
+ raise ValidationError({
+ 'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
+ })
+
+ # Validate manufacturer/platform
+ if hasattr(self, 'device_type') and self.platform:
+ if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
+ raise ValidationError({
+ 'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
+ "to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
+ })
+
+ # A Device can only be assigned to a Cluster in the same Site (or no Site)
+ if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
+ raise ValidationError({
+ 'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
+ })
+
+ # Validate virtual chassis assignment
+ if self.virtual_chassis and self.vc_position is None:
+ raise ValidationError({
+ 'vc_position': "A device assigned to a virtual chassis must have its position defined."
+ })
+
+ def save(self, *args, **kwargs):
+
+ is_new = not bool(self.pk)
+
+ super().save(*args, **kwargs)
+
+ # If this is a new Device, instantiate all of the related components per the DeviceType definition
+ if is_new:
+ ConsolePort.objects.bulk_create(
+ [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()]
+ )
+ ConsoleServerPort.objects.bulk_create(
+ [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()]
+ )
+ PowerPort.objects.bulk_create(
+ [x.instantiate(self) for x in self.device_type.powerporttemplates.all()]
+ )
+ PowerOutlet.objects.bulk_create(
+ [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()]
+ )
+ Interface.objects.bulk_create(
+ [x.instantiate(self) for x in self.device_type.interfacetemplates.all()]
+ )
+ RearPort.objects.bulk_create(
+ [x.instantiate(self) for x in self.device_type.rearporttemplates.all()]
+ )
+ FrontPort.objects.bulk_create(
+ [x.instantiate(self) for x in self.device_type.frontporttemplates.all()]
+ )
+ DeviceBay.objects.bulk_create(
+ [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()]
+ )
+
+ # Update Site and Rack assignment for any child Devices
+ devices = Device.objects.filter(parent_bay__device=self)
+ for device in devices:
+ device.site = self.site
+ device.rack = self.rack
+ device.save()
+
+ def to_csv(self):
+ return (
+ self.name or '',
+ self.device_role.name,
+ self.tenant.name if self.tenant else None,
+ self.device_type.manufacturer.name,
+ self.device_type.model,
+ self.platform.name if self.platform else None,
+ self.serial,
+ self.asset_tag,
+ self.get_status_display(),
+ self.site.name,
+ self.rack.group.name if self.rack and self.rack.group else None,
+ self.rack.name if self.rack else None,
+ self.position,
+ self.get_face_display(),
+ self.comments,
+ )
+
+ @property
+ def display_name(self):
+ if self.name:
+ return self.name
+ elif self.virtual_chassis:
+ return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
+ elif self.device_type:
+ return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
+ else:
+ return '' # Device has not yet been created
+
+ @property
+ def identifier(self):
+ """
+ Return the device name if set; otherwise return the Device's primary key as {pk}
+ """
+ if self.name is not None:
+ return self.name
+ return '{{{}}}'.format(self.pk)
+
+ @property
+ def primary_ip(self):
+ if settings.PREFER_IPV4 and self.primary_ip4:
+ return self.primary_ip4
+ elif self.primary_ip6:
+ return self.primary_ip6
+ elif self.primary_ip4:
+ return self.primary_ip4
+ else:
+ return None
+
+ def get_vc_master(self):
+ """
+ If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
+ """
+ return self.virtual_chassis.master if self.virtual_chassis else None
+
+ @property
+ def vc_interfaces(self):
+ """
+ Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
+ Device belonging to the same VirtualChassis.
+ """
+ filter = Q(device=self)
+ if self.virtual_chassis and self.virtual_chassis.master == self:
+ filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
+ return Interface.objects.filter(filter)
+
+ def get_cables(self, pk_list=False):
+ """
+ Return a QuerySet or PK list matching all Cables connected to a component of this Device.
+ """
+ cable_pks = []
+ for component_model in [
+ ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
+ ]:
+ cable_pks += component_model.objects.filter(
+ device=self, cable__isnull=False
+ ).values_list('cable', flat=True)
+ if pk_list:
+ return cable_pks
+ return Cable.objects.filter(pk__in=cable_pks)
+
+ def get_children(self):
+ """
+ Return the set of child Devices installed in DeviceBays within this Device.
+ """
+ return Device.objects.filter(parent_bay__device=self.pk)
+
+ def get_status_class(self):
+ return self.STATUS_CLASS_MAP.get(self.status)
+
+
+#
+# Cables
+#
+
+@extras_features('custom_links', 'export_templates', 'webhooks')
+class Cable(ChangeLoggedModel):
+ """
+ A physical connection between two endpoints.
+ """
+ termination_a_type = models.ForeignKey(
+ to=ContentType,
+ limit_choices_to=CABLE_TERMINATION_MODELS,
+ on_delete=models.PROTECT,
+ related_name='+'
+ )
+ termination_a_id = models.PositiveIntegerField()
+ termination_a = GenericForeignKey(
+ ct_field='termination_a_type',
+ fk_field='termination_a_id'
+ )
+ termination_b_type = models.ForeignKey(
+ to=ContentType,
+ limit_choices_to=CABLE_TERMINATION_MODELS,
+ on_delete=models.PROTECT,
+ related_name='+'
+ )
+ termination_b_id = models.PositiveIntegerField()
+ termination_b = GenericForeignKey(
+ ct_field='termination_b_type',
+ fk_field='termination_b_id'
+ )
+ type = models.CharField(
+ max_length=50,
+ choices=CableTypeChoices,
+ blank=True
+ )
+ status = models.CharField(
+ max_length=50,
+ choices=CableStatusChoices,
+ default=CableStatusChoices.STATUS_CONNECTED
+ )
+ label = models.CharField(
+ max_length=100,
+ blank=True
+ )
+ color = ColorField(
+ blank=True
+ )
+ length = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True
+ )
+ length_unit = models.CharField(
+ max_length=50,
+ choices=CableLengthUnitChoices,
+ blank=True,
+ )
+ # Stores the normalized length (in meters) for database ordering
+ _abs_length = models.DecimalField(
+ max_digits=10,
+ decimal_places=4,
+ blank=True,
+ null=True
+ )
+ # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
+ # their associated Devices.
+ _termination_a_device = models.ForeignKey(
+ to=Device,
+ on_delete=models.CASCADE,
+ related_name='+',
+ blank=True,
+ null=True
+ )
+ _termination_b_device = models.ForeignKey(
+ to=Device,
+ on_delete=models.CASCADE,
+ related_name='+',
+ blank=True,
+ null=True
+ )
+ tags = TaggableManager(through=TaggedItem)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = [
+ 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
+ 'color', 'length', 'length_unit',
+ ]
+
+ STATUS_CLASS_MAP = {
+ CableStatusChoices.STATUS_CONNECTED: 'success',
+ CableStatusChoices.STATUS_PLANNED: 'info',
+ CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
+ }
+
+ class Meta:
+ ordering = ['pk']
+ unique_together = (
+ ('termination_a_type', 'termination_a_id'),
+ ('termination_b_type', 'termination_b_id'),
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # A copy of the PK to be used by __str__ in case the object is deleted
+ self._pk = self.pk
+
+ @classmethod
+ def from_db(cls, db, field_names, values):
+ """
+ Cache the original A and B terminations of existing Cable instances for later reference inside clean().
+ """
+ instance = super().from_db(db, field_names, values)
+
+ instance._orig_termination_a_type_id = instance.termination_a_type_id
+ instance._orig_termination_a_id = instance.termination_a_id
+ instance._orig_termination_b_type_id = instance.termination_b_type_id
+ instance._orig_termination_b_id = instance.termination_b_id
+
+ return instance
+
+ def __str__(self):
+ return self.label or '#{}'.format(self._pk)
+
+ def get_absolute_url(self):
+ return reverse('dcim:cable', args=[self.pk])
+
+ def clean(self):
+ from circuits.models import CircuitTermination
+
+ # Validate that termination A exists
+ if not hasattr(self, 'termination_a_type'):
+ raise ValidationError('Termination A type has not been specified')
+ try:
+ self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
+ except ObjectDoesNotExist:
+ raise ValidationError({
+ 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
+ })
+
+ # Validate that termination B exists
+ if not hasattr(self, 'termination_b_type'):
+ raise ValidationError('Termination B type has not been specified')
+ try:
+ self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
+ except ObjectDoesNotExist:
+ raise ValidationError({
+ 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
+ })
+
+ # If editing an existing Cable instance, check that neither termination has been modified.
+ if self.pk:
+ err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
+ if (
+ self.termination_a_type_id != self._orig_termination_a_type_id or
+ self.termination_a_id != self._orig_termination_a_id
+ ):
+ raise ValidationError({
+ 'termination_a': err_msg
+ })
+ if (
+ self.termination_b_type_id != self._orig_termination_b_type_id or
+ self.termination_b_id != self._orig_termination_b_id
+ ):
+ raise ValidationError({
+ 'termination_b': err_msg
+ })
+
+ type_a = self.termination_a_type.model
+ type_b = self.termination_b_type.model
+
+ # Validate interface types
+ if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
+ raise ValidationError({
+ 'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
+ self.termination_a.get_type_display()
+ )
+ })
+ if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
+ raise ValidationError({
+ 'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
+ self.termination_b.get_type_display()
+ )
+ })
+
+ # Check that termination types are compatible
+ if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
+ raise ValidationError(
+ f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
+ )
+
+ # Check that a RearPort with multiple positions isn't connected to an endpoint
+ # or a RearPort with a different number of positions.
+ for term_a, term_b in [
+ (self.termination_a, self.termination_b),
+ (self.termination_b, self.termination_a)
+ ]:
+ if isinstance(term_a, RearPort) and term_a.positions > 1:
+ if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
+ raise ValidationError(
+ "Rear ports with multiple positions may only be connected to other pass-through ports"
+ )
+ if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
+ raise ValidationError(
+ f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
+ f"{term_b} of {term_b.device} has {term_b.positions}. "
+ f"Both terminations must have the same number of positions."
+ )
+
+ # A termination point cannot be connected to itself
+ if self.termination_a == self.termination_b:
+ raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
+
+ # A front port cannot be connected to its corresponding rear port
+ if (
+ type_a in ['frontport', 'rearport'] and
+ type_b in ['frontport', 'rearport'] and
+ (
+ getattr(self.termination_a, 'rear_port', None) == self.termination_b or
+ getattr(self.termination_b, 'rear_port', None) == self.termination_a
+ )
+ ):
+ raise ValidationError("A front port cannot be connected to it corresponding rear port")
+
+ # Check for an existing Cable connected to either termination object
+ if self.termination_a.cable not in (None, self):
+ raise ValidationError("{} already has a cable attached (#{})".format(
+ self.termination_a, self.termination_a.cable_id
+ ))
+ if self.termination_b.cable not in (None, self):
+ raise ValidationError("{} already has a cable attached (#{})".format(
+ self.termination_b, self.termination_b.cable_id
+ ))
+
+ # Validate length and length_unit
+ if self.length is not None and not self.length_unit:
+ raise ValidationError("Must specify a unit when setting a cable length")
+ elif self.length is None:
+ self.length_unit = ''
+
+ def save(self, *args, **kwargs):
+
+ # Store the given length (if any) in meters for use in database ordering
+ if self.length and self.length_unit:
+ self._abs_length = to_meters(self.length, self.length_unit)
+ else:
+ self._abs_length = None
+
+ # Store the parent Device for the A and B terminations (if applicable) to enable filtering
+ if hasattr(self.termination_a, 'device'):
+ self._termination_a_device = self.termination_a.device
+ if hasattr(self.termination_b, 'device'):
+ self._termination_b_device = self.termination_b.device
+
+ super().save(*args, **kwargs)
+
+ # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
+ self._pk = self.pk
+
+ def to_csv(self):
+ return (
+ '{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
+ self.termination_a_id,
+ '{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
+ self.termination_b_id,
+ self.get_type_display(),
+ self.get_status_display(),
+ self.label,
+ self.color,
+ self.length,
+ self.length_unit,
+ )
+
+ def get_status_class(self):
+ return self.STATUS_CLASS_MAP.get(self.status)
+
+ 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]
+
+
+#
+# Virtual chassis
+#
+
+@extras_features('custom_links', 'export_templates', 'webhooks')
+class VirtualChassis(ChangeLoggedModel):
+ """
+ A collection of Devices which operate with a shared control plane (e.g. a switch stack).
+ """
+ master = models.OneToOneField(
+ to='Device',
+ on_delete=models.PROTECT,
+ related_name='vc_master_for',
+ blank=True,
+ null=True
+ )
+ name = models.CharField(
+ max_length=64
+ )
+ domain = models.CharField(
+ max_length=30,
+ blank=True
+ )
+ tags = TaggableManager(through=TaggedItem)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = ['name', 'domain', 'master']
+
+ class Meta:
+ ordering = ['name']
+ verbose_name_plural = 'virtual chassis'
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
+
+ def clean(self):
+
+ # Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
+ # VirtualChassis.)
+ if self.pk and self.master and self.master not in self.members.all():
+ raise ValidationError({
+ 'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
+ })
+
+ def delete(self, *args, **kwargs):
+
+ # Check for LAG interfaces split across member chassis
+ interfaces = Interface.objects.filter(
+ device__in=self.members.all(),
+ lag__isnull=False
+ ).exclude(
+ lag__device=F('device')
+ )
+ if interfaces:
+ raise ProtectedError(
+ f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
+ interfaces
+ )
+
+ return super().delete(*args, **kwargs)
+
+ def to_csv(self):
+ return (
+ self.name,
+ self.domain,
+ self.master.name if self.master else None,
+ )
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
new file mode 100644
index 000000000..f760fea13
--- /dev/null
+++ b/netbox/dcim/models/power.py
@@ -0,0 +1,237 @@
+from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.urls import reverse
+from taggit.managers import TaggableManager
+
+from dcim.choices import *
+from dcim.constants import *
+from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem
+from extras.utils import extras_features
+from utilities.querysets import RestrictedQuerySet
+from utilities.validators import ExclusionValidator
+from .device_components import CableTermination
+
+__all__ = (
+ 'PowerFeed',
+ 'PowerPanel',
+)
+
+
+#
+# Power
+#
+
+@extras_features('custom_links', 'export_templates', 'webhooks')
+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
+ )
+ tags = TaggableManager(through=TaggedItem)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = ['site', 'rack_group', '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
+ ))
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+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.BooleanField(
+ choices=CONNECTION_STATUS_CHOICES,
+ blank=True,
+ null=True
+ )
+ name = models.CharField(
+ max_length=50
+ )
+ status = models.CharField(
+ max_length=50,
+ choices=PowerFeedStatusChoices,
+ default=PowerFeedStatusChoices.STATUS_ACTIVE
+ )
+ type = models.CharField(
+ max_length=50,
+ choices=PowerFeedTypeChoices,
+ default=PowerFeedTypeChoices.TYPE_PRIMARY
+ )
+ supply = models.CharField(
+ max_length=50,
+ choices=PowerFeedSupplyChoices,
+ default=PowerFeedSupplyChoices.SUPPLY_AC
+ )
+ phase = models.CharField(
+ max_length=50,
+ choices=PowerFeedPhaseChoices,
+ default=PowerFeedPhaseChoices.PHASE_SINGLE
+ )
+ voltage = models.SmallIntegerField(
+ default=POWERFEED_VOLTAGE_DEFAULT,
+ validators=[ExclusionValidator([0])]
+ )
+ amperage = models.PositiveSmallIntegerField(
+ validators=[MinValueValidator(1)],
+ default=POWERFEED_AMPERAGE_DEFAULT
+ )
+ max_utilization = models.PositiveSmallIntegerField(
+ validators=[MinValueValidator(1), MaxValueValidator(100)],
+ default=POWERFEED_MAX_UTILIZATION_DEFAULT,
+ help_text="Maximum permissible draw (percentage)"
+ )
+ available_power = models.PositiveIntegerField(
+ 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)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = [
+ 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
+ 'amperage', 'max_utilization', 'comments',
+ ]
+ clone_fields = [
+ 'power_panel', 'rack', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
+ 'available_power',
+ ]
+
+ STATUS_CLASS_MAP = {
+ PowerFeedStatusChoices.STATUS_OFFLINE: 'warning',
+ PowerFeedStatusChoices.STATUS_ACTIVE: 'success',
+ PowerFeedStatusChoices.STATUS_PLANNED: 'info',
+ PowerFeedStatusChoices.STATUS_FAILED: 'danger',
+ }
+
+ TYPE_CLASS_MAP = {
+ PowerFeedTypeChoices.TYPE_PRIMARY: 'success',
+ PowerFeedTypeChoices.TYPE_REDUNDANT: 'info',
+ }
+
+ 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.site.name,
+ self.power_panel.name,
+ self.rack.group.name if self.rack and self.rack.group else None,
+ 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
+ ))
+
+ # AC voltage cannot be negative
+ if self.voltage < 0 and self.supply == PowerFeedSupplyChoices.SUPPLY_AC:
+ raise ValidationError({
+ "voltage": "Voltage cannot be negative for AC supply"
+ })
+
+ def save(self, *args, **kwargs):
+
+ # Cache the available_power property on the instance
+ kva = abs(self.voltage) * self.amperage * (self.max_utilization / 100)
+ if self.phase == PowerFeedPhaseChoices.PHASE_3PHASE:
+ self.available_power = round(kva * 1.732)
+ else:
+ self.available_power = round(kva)
+
+ super().save(*args, **kwargs)
+
+ @property
+ def parent(self):
+ return self.power_panel
+
+ def get_type_class(self):
+ return self.TYPE_CLASS_MAP.get(self.type)
+
+ def get_status_class(self):
+ return self.STATUS_CLASS_MAP.get(self.status)
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
new file mode 100644
index 000000000..3169272b4
--- /dev/null
+++ b/netbox/dcim/models/racks.py
@@ -0,0 +1,655 @@
+from collections import OrderedDict
+from itertools import count, groupby
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.postgres.fields import ArrayField
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+from django.db.models import Count, Sum
+from django.urls import reverse
+from mptt.models import MPTTModel, TreeForeignKey
+from taggit.managers import TaggableManager
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.elevations import RackElevationSVG
+from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
+from utilities.choices import ColorChoices
+from utilities.fields import ColorField, NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
+from utilities.mptt import TreeManager
+from utilities.utils import serialize_object
+from .devices import Device
+from .power import PowerFeed
+
+__all__ = (
+ 'Rack',
+ 'RackGroup',
+ 'RackReservation',
+ 'RackRole',
+)
+
+
+#
+# Racks
+#
+
+@extras_features('export_templates')
+class RackGroup(MPTTModel, ChangeLoggedModel):
+ """
+ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
+ example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that
+ campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor.
+ """
+ name = models.CharField(
+ max_length=50
+ )
+ slug = models.SlugField()
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.CASCADE,
+ related_name='rack_groups'
+ )
+ parent = TreeForeignKey(
+ to='self',
+ on_delete=models.CASCADE,
+ related_name='children',
+ blank=True,
+ null=True,
+ db_index=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+
+ objects = TreeManager()
+
+ csv_headers = ['site', 'parent', 'name', 'slug', 'description']
+
+ class Meta:
+ ordering = ['site', 'name']
+ unique_together = [
+ ['site', 'name'],
+ ['site', 'slug'],
+ ]
+
+ class MPTTMeta:
+ order_insertion_by = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
+
+ def to_csv(self):
+ return (
+ self.site,
+ self.parent.name if self.parent else '',
+ self.name,
+ self.slug,
+ self.description,
+ )
+
+ def to_objectchange(self, action):
+ # Remove MPTT-internal fields
+ return ObjectChange(
+ changed_object=self,
+ object_repr=str(self),
+ action=action,
+ object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
+ )
+
+ def clean(self):
+
+ # Parent RackGroup (if any) must belong to the same Site
+ if self.parent and self.parent.site != self.site:
+ raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
+
+
+class RackRole(ChangeLoggedModel):
+ """
+ Racks can be organized by functional role, similar to Devices.
+ """
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ color = ColorField(
+ default=ColorChoices.COLOR_GREY
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True,
+ )
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = ['name', 'slug', 'color', 'description']
+
+ class Meta:
+ ordering = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
+
+ def to_csv(self):
+ return (
+ self.name,
+ self.slug,
+ self.color,
+ self.description,
+ )
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class Rack(ChangeLoggedModel, CustomFieldModel):
+ """
+ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
+ Each Rack is assigned to a Site and (optionally) a RackGroup.
+ """
+ name = models.CharField(
+ max_length=50
+ )
+ _name = NaturalOrderingField(
+ target_field='name',
+ max_length=100,
+ blank=True
+ )
+ facility_id = models.CharField(
+ max_length=50,
+ blank=True,
+ null=True,
+ verbose_name='Facility ID',
+ help_text='Locally-assigned identifier'
+ )
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='racks'
+ )
+ group = models.ForeignKey(
+ to='dcim.RackGroup',
+ on_delete=models.SET_NULL,
+ related_name='racks',
+ blank=True,
+ null=True,
+ help_text='Assigned group'
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='racks',
+ blank=True,
+ null=True
+ )
+ status = models.CharField(
+ max_length=50,
+ choices=RackStatusChoices,
+ default=RackStatusChoices.STATUS_ACTIVE
+ )
+ role = models.ForeignKey(
+ to='dcim.RackRole',
+ on_delete=models.PROTECT,
+ related_name='racks',
+ blank=True,
+ null=True,
+ help_text='Functional role'
+ )
+ serial = models.CharField(
+ max_length=50,
+ blank=True,
+ verbose_name='Serial number'
+ )
+ asset_tag = models.CharField(
+ max_length=50,
+ blank=True,
+ null=True,
+ unique=True,
+ verbose_name='Asset tag',
+ help_text='A unique tag used to identify this rack'
+ )
+ type = models.CharField(
+ choices=RackTypeChoices,
+ max_length=50,
+ blank=True,
+ verbose_name='Type'
+ )
+ width = models.PositiveSmallIntegerField(
+ choices=RackWidthChoices,
+ default=RackWidthChoices.WIDTH_19IN,
+ verbose_name='Width',
+ help_text='Rail-to-rail width'
+ )
+ u_height = models.PositiveSmallIntegerField(
+ default=RACK_U_HEIGHT_DEFAULT,
+ verbose_name='Height (U)',
+ validators=[MinValueValidator(1), MaxValueValidator(100)],
+ help_text='Height in rack units'
+ )
+ desc_units = models.BooleanField(
+ default=False,
+ verbose_name='Descending units',
+ help_text='Units are numbered top-to-bottom'
+ )
+ outer_width = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ help_text='Outer dimension of rack (width)'
+ )
+ outer_depth = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ help_text='Outer dimension of rack (depth)'
+ )
+ outer_unit = models.CharField(
+ max_length=50,
+ choices=RackDimensionUnitChoices,
+ blank=True,
+ )
+ comments = models.TextField(
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
+ images = GenericRelation(
+ to='extras.ImageAttachment'
+ )
+ tags = TaggableManager(through=TaggedItem)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = [
+ 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
+ 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
+ ]
+ clone_fields = [
+ 'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
+ 'outer_depth', 'outer_unit',
+ ]
+
+ STATUS_CLASS_MAP = {
+ RackStatusChoices.STATUS_RESERVED: 'warning',
+ RackStatusChoices.STATUS_AVAILABLE: 'success',
+ RackStatusChoices.STATUS_PLANNED: 'info',
+ RackStatusChoices.STATUS_ACTIVE: 'primary',
+ RackStatusChoices.STATUS_DEPRECATED: 'danger',
+ }
+
+ class Meta:
+ ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
+ unique_together = (
+ # Name and facility_id must be unique *only* within a RackGroup
+ ('group', 'name'),
+ ('group', 'facility_id'),
+ )
+
+ def __str__(self):
+ return self.display_name or super().__str__()
+
+ def get_absolute_url(self):
+ return reverse('dcim:rack', args=[self.pk])
+
+ def clean(self):
+
+ # Validate outer dimensions and unit
+ if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
+ raise ValidationError("Must specify a unit when setting an outer width/depth")
+ elif self.outer_width is None and self.outer_depth is None:
+ self.outer_unit = ''
+
+ if self.pk:
+ # Validate that Rack is tall enough to house the installed Devices
+ top_device = Device.objects.filter(
+ rack=self
+ ).exclude(
+ position__isnull=True
+ ).order_by('-position').first()
+ if top_device:
+ min_height = top_device.position + top_device.device_type.u_height - 1
+ if self.u_height < min_height:
+ raise ValidationError({
+ 'u_height': "Rack must be at least {}U tall to house currently installed devices.".format(
+ min_height
+ )
+ })
+ # Validate that Rack was assigned a group of its same site, if applicable
+ if self.group:
+ if self.group.site != self.site:
+ raise ValidationError({
+ 'group': "Rack group must be from the same site, {}.".format(self.site)
+ })
+
+ def save(self, *args, **kwargs):
+
+ # Record the original site assignment for this rack.
+ _site_id = None
+ if self.pk:
+ _site_id = Rack.objects.get(pk=self.pk).site_id
+
+ super().save(*args, **kwargs)
+
+ # Update racked devices if the assigned Site has been changed.
+ if _site_id is not None and self.site_id != _site_id:
+ devices = Device.objects.filter(rack=self)
+ for device in devices:
+ device.site = self.site
+ device.save()
+
+ def to_csv(self):
+ return (
+ self.site.name,
+ self.group.name if self.group else None,
+ self.name,
+ self.facility_id,
+ self.tenant.name if self.tenant else None,
+ self.get_status_display(),
+ self.role.name if self.role else None,
+ self.get_type_display() if self.type else None,
+ self.serial,
+ self.asset_tag,
+ self.width,
+ self.u_height,
+ self.desc_units,
+ self.outer_width,
+ self.outer_depth,
+ self.outer_unit,
+ self.comments,
+ )
+
+ @property
+ def units(self):
+ if self.desc_units:
+ return range(1, self.u_height + 1)
+ else:
+ return reversed(range(1, self.u_height + 1))
+
+ @property
+ def display_name(self):
+ if self.facility_id:
+ return f'{self.name} ({self.facility_id})'
+ return self.name
+
+ def get_status_class(self):
+ return self.STATUS_CLASS_MAP.get(self.status)
+
+ def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True):
+ """
+ Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'}
+ Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy.
+
+ :param face: Rack face (front or rear)
+ :param user: User instance to be used for evaluating device view permissions. If None, all devices
+ will be included.
+ :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack
+ :param expand_devices: When True, all units that a device occupies will be listed with each containing a
+ reference to the device. When False, only the bottom most unit for a device is included and that unit
+ contains a height attribute for the device
+ """
+
+ elevation = OrderedDict()
+ for u in self.units:
+ elevation[u] = {
+ 'id': u,
+ 'name': f'U{u}',
+ 'face': face,
+ 'device': None,
+ 'occupied': False
+ }
+
+ # Add devices to rack units list
+ if self.pk:
+
+ # Retrieve all devices installed within the rack
+ queryset = Device.objects.prefetch_related(
+ 'device_type',
+ 'device_type__manufacturer',
+ 'device_role'
+ ).annotate(
+ devicebay_count=Count('devicebays')
+ ).exclude(
+ pk=exclude
+ ).filter(
+ rack=self,
+ position__gt=0,
+ device_type__u_height__gt=0
+ ).filter(
+ Q(face=face) | Q(device_type__is_full_depth=True)
+ )
+
+ # Determine which devices the user has permission to view
+ permitted_device_ids = []
+ if user is not None:
+ permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
+
+ for device in queryset:
+ if expand_devices:
+ for u in range(device.position, device.position + device.device_type.u_height):
+ if user is None or device.pk in permitted_device_ids:
+ elevation[u]['device'] = device
+ elevation[u]['occupied'] = True
+ else:
+ if user is None or device.pk in permitted_device_ids:
+ elevation[device.position]['device'] = device
+ elevation[device.position]['occupied'] = True
+ elevation[device.position]['height'] = device.device_type.u_height
+ for u in range(device.position + 1, device.position + device.device_type.u_height):
+ elevation.pop(u, None)
+
+ return [u for u in elevation.values()]
+
+ def get_available_units(self, u_height=1, rack_face=None, exclude=None):
+ """
+ Return a list of units within the rack available to accommodate a device of a given U height (default 1).
+ Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
+ position to another within a rack).
+
+ :param u_height: Minimum number of contiguous free units required
+ :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
+ :param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
+ """
+ # Gather all devices which consume U space within the rack
+ devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
+ if exclude is not None:
+ devices = devices.exclude(pk__in=exclude)
+
+ # Initialize the rack unit skeleton
+ units = list(range(1, self.u_height + 1))
+
+ # Remove units consumed by installed devices
+ for d in devices:
+ if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
+ for u in range(d.position, d.position + d.device_type.u_height):
+ try:
+ units.remove(u)
+ except ValueError:
+ # Found overlapping devices in the rack!
+ pass
+
+ # Remove units without enough space above them to accommodate a device of the specified height
+ available_units = []
+ for u in units:
+ if set(range(u, u + u_height)).issubset(units):
+ available_units.append(u)
+
+ return list(reversed(available_units))
+
+ def get_reserved_units(self):
+ """
+ Return a dictionary mapping all reserved units within the rack to their reservation.
+ """
+ reserved_units = {}
+ for r in self.reservations.all():
+ for u in r.units:
+ reserved_units[u] = r
+ return reserved_units
+
+ def get_elevation_svg(
+ self,
+ face=DeviceFaceChoices.FACE_FRONT,
+ user=None,
+ unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
+ unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
+ legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
+ include_images=True,
+ base_url=None
+ ):
+ """
+ Return an SVG of the rack elevation
+
+ :param face: Enum of [front, rear] representing the desired side of the rack elevation to render
+ :param user: User instance to be used for evaluating device view permissions. If None, all devices
+ will be included.
+ :param unit_width: Width in pixels for the rendered drawing
+ :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
+ height of the elevation
+ :param legend_width: Width of the unit legend, in pixels
+ :param include_images: Embed front/rear device images where available
+ :param base_url: Base URL for links and images. If none, URLs will be relative.
+ """
+ elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
+
+ return elevation.render(face, unit_width, unit_height, legend_width)
+
+ def get_0u_devices(self):
+ return self.devices.filter(position=0)
+
+ def get_utilization(self):
+ """
+ Determine the utilization rate of the rack and return it as a percentage. Occupied and reserved units both count
+ as utilized.
+ """
+ # Determine unoccupied units
+ available_units = self.get_available_units()
+
+ # Remove reserved units
+ for u in self.get_reserved_units():
+ if u in available_units:
+ available_units.remove(u)
+
+ occupied_unit_count = self.u_height - len(available_units)
+ percentage = int(float(occupied_unit_count) / self.u_height * 100)
+
+ return percentage
+
+ 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'] or 0 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
+
+
+@extras_features('custom_links', 'export_templates', 'webhooks')
+class RackReservation(ChangeLoggedModel):
+ """
+ One or more reserved units within a Rack.
+ """
+ rack = models.ForeignKey(
+ to='dcim.Rack',
+ on_delete=models.CASCADE,
+ related_name='reservations'
+ )
+ units = ArrayField(
+ base_field=models.PositiveSmallIntegerField()
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='rackreservations',
+ blank=True,
+ null=True
+ )
+ user = models.ForeignKey(
+ to=User,
+ on_delete=models.PROTECT
+ )
+ description = models.CharField(
+ max_length=200
+ )
+ tags = TaggableManager(through=TaggedItem)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
+
+ class Meta:
+ ordering = ['created']
+
+ def __str__(self):
+ return "Reservation for rack {}".format(self.rack)
+
+ def get_absolute_url(self):
+ return reverse('dcim:rackreservation', args=[self.pk])
+
+ def clean(self):
+
+ if hasattr(self, 'rack') and self.units:
+
+ # Validate that all specified units exist in the Rack.
+ invalid_units = [u for u in self.units if u not in self.rack.units]
+ if invalid_units:
+ raise ValidationError({
+ 'units': "Invalid unit(s) for {}U rack: {}".format(
+ self.rack.u_height,
+ ', '.join([str(u) for u in invalid_units]),
+ ),
+ })
+
+ # Check that none of the units has already been reserved for this Rack.
+ reserved_units = []
+ for resv in self.rack.reservations.exclude(pk=self.pk):
+ reserved_units += resv.units
+ conflicting_units = [u for u in self.units if u in reserved_units]
+ if conflicting_units:
+ raise ValidationError({
+ 'units': 'The following units have already been reserved: {}'.format(
+ ', '.join([str(u) for u in conflicting_units]),
+ )
+ })
+
+ def to_csv(self):
+ return (
+ self.rack.site.name,
+ self.rack.group if self.rack.group else None,
+ self.rack.name,
+ ','.join([str(u) for u in self.units]),
+ self.tenant.name if self.tenant else None,
+ self.user.username,
+ self.description
+ )
+
+ @property
+ def unit_list(self):
+ """
+ Express the assigned units as a string of summarized ranges. For example:
+ [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
+ """
+ group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
+ return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
new file mode 100644
index 000000000..daf7055db
--- /dev/null
+++ b/netbox/dcim/models/sites.py
@@ -0,0 +1,246 @@
+from django.contrib.contenttypes.fields import GenericRelation
+from django.db import models
+from django.urls import reverse
+from mptt.models import MPTTModel, TreeForeignKey
+from taggit.managers import TaggableManager
+from timezone_field import TimeZoneField
+
+from dcim.choices import *
+from dcim.constants import *
+from dcim.fields import ASNField
+from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
+from utilities.fields import NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
+from utilities.mptt import TreeManager
+from utilities.utils import serialize_object
+
+__all__ = (
+ 'Region',
+ 'Site',
+)
+
+
+#
+# Regions
+#
+
+@extras_features('export_templates', 'webhooks')
+class Region(MPTTModel, ChangeLoggedModel):
+ """
+ Sites can be grouped within geographic Regions.
+ """
+ parent = TreeForeignKey(
+ to='self',
+ on_delete=models.CASCADE,
+ related_name='children',
+ blank=True,
+ null=True,
+ db_index=True
+ )
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+
+ objects = TreeManager()
+
+ csv_headers = ['name', 'slug', 'parent', 'description']
+
+ class MPTTMeta:
+ order_insertion_by = ['name']
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
+
+ def to_csv(self):
+ return (
+ self.name,
+ self.slug,
+ self.parent.name if self.parent else None,
+ self.description,
+ )
+
+ def get_site_count(self):
+ return Site.objects.filter(
+ Q(region=self) |
+ Q(region__in=self.get_descendants())
+ ).count()
+
+ def to_objectchange(self, action):
+ # Remove MPTT-internal fields
+ return ObjectChange(
+ changed_object=self,
+ object_repr=str(self),
+ action=action,
+ object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
+ )
+
+
+#
+# Sites
+#
+
+@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
+class Site(ChangeLoggedModel, CustomFieldModel):
+ """
+ A Site represents a geographic location within a network; typically a building or campus. The optional facility
+ field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
+ """
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ _name = NaturalOrderingField(
+ target_field='name',
+ max_length=100,
+ blank=True
+ )
+ slug = models.SlugField(
+ unique=True
+ )
+ status = models.CharField(
+ max_length=50,
+ choices=SiteStatusChoices,
+ default=SiteStatusChoices.STATUS_ACTIVE
+ )
+ region = models.ForeignKey(
+ to='dcim.Region',
+ on_delete=models.SET_NULL,
+ related_name='sites',
+ blank=True,
+ null=True
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='sites',
+ blank=True,
+ null=True
+ )
+ facility = models.CharField(
+ max_length=50,
+ blank=True,
+ help_text='Local facility ID or description'
+ )
+ asn = ASNField(
+ blank=True,
+ null=True,
+ verbose_name='ASN',
+ help_text='32-bit autonomous system number'
+ )
+ time_zone = TimeZoneField(
+ blank=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ physical_address = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ shipping_address = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ latitude = models.DecimalField(
+ max_digits=8,
+ decimal_places=6,
+ blank=True,
+ null=True,
+ help_text='GPS coordinate (latitude)'
+ )
+ longitude = models.DecimalField(
+ max_digits=9,
+ decimal_places=6,
+ blank=True,
+ null=True,
+ help_text='GPS coordinate (longitude)'
+ )
+ contact_name = models.CharField(
+ max_length=50,
+ blank=True
+ )
+ contact_phone = models.CharField(
+ max_length=20,
+ blank=True
+ )
+ contact_email = models.EmailField(
+ blank=True,
+ verbose_name='Contact E-mail'
+ )
+ comments = models.TextField(
+ blank=True
+ )
+ custom_field_values = GenericRelation(
+ to='extras.CustomFieldValue',
+ content_type_field='obj_type',
+ object_id_field='obj_id'
+ )
+ images = GenericRelation(
+ to='extras.ImageAttachment'
+ )
+ tags = TaggableManager(through=TaggedItem)
+
+ objects = RestrictedQuerySet.as_manager()
+
+ csv_headers = [
+ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
+ 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
+ ]
+ clone_fields = [
+ 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
+ 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
+ ]
+
+ STATUS_CLASS_MAP = {
+ SiteStatusChoices.STATUS_PLANNED: 'info',
+ SiteStatusChoices.STATUS_STAGING: 'primary',
+ SiteStatusChoices.STATUS_ACTIVE: 'success',
+ SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
+ SiteStatusChoices.STATUS_RETIRED: 'danger',
+ }
+
+ class Meta:
+ ordering = ('_name',)
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('dcim:site', args=[self.slug])
+
+ def to_csv(self):
+ return (
+ self.name,
+ self.slug,
+ self.get_status_display(),
+ self.region.name if self.region else None,
+ self.tenant.name if self.tenant else None,
+ self.facility,
+ self.asn,
+ self.time_zone,
+ self.description,
+ self.physical_address,
+ self.shipping_address,
+ self.latitude,
+ self.longitude,
+ self.contact_name,
+ self.contact_phone,
+ self.contact_email,
+ self.comments,
+ )
+
+ def get_status_class(self):
+ return self.STATUS_CLASS_MAP.get(self.status)
diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py
index 8c8ac67bc..172c366b5 100644
--- a/netbox/dcim/signals.py
+++ b/netbox/dcim/signals.py
@@ -10,14 +10,13 @@ from .models import Cable, CableTermination, Device, FrontPort, RearPort, Virtua
@receiver(post_save, sender=VirtualChassis)
def assign_virtualchassis_master(instance, created, **kwargs):
"""
- When a VirtualChassis is created, automatically assign its master device to the VC.
+ When a VirtualChassis is created, automatically assign its master device (if any) to the VC.
"""
- if created:
- devices = Device.objects.filter(pk=instance.master.pk)
- for device in devices:
- device.virtual_chassis = instance
- device.vc_position = None
- device.save()
+ if created and instance.master:
+ master = Device.objects.get(pk=instance.master.pk)
+ master.virtual_chassis = instance
+ master.vc_position = 1
+ master.save()
@receiver(pre_delete, sender=VirtualChassis)
diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py
index 93ef724e0..e48eaedba 100644
--- a/netbox/dcim/tables.py
+++ b/netbox/dcim/tables.py
@@ -2,7 +2,9 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
-from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn
+from utilities.tables import (
+ BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn,
+)
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -40,42 +42,20 @@ DEVICE_LINK = """
"""
-REGION_ACTIONS = """
-
-
-
-{% if perms.dcim.change_region %}
-
-{% endif %}
-"""
-
-RACKGROUP_ACTIONS = """
-
-
-
+RACKGROUP_ELEVATIONS = """
-{% if perms.dcim.change_rackgroup %}
-
-
-
-{% endif %}
-"""
-
-RACKROLE_ACTIONS = """
-
-
-
-{% if perms.dcim.change_rackrole %}
-
-{% endif %}
"""
RACK_DEVICE_COUNT = """
{{ value }}
"""
+DEVICE_COUNT = """
+{{ value|default:0 }}
+"""
+
RACKRESERVATION_ACTIONS = """
@@ -119,15 +99,6 @@ PLATFORM_VM_COUNT = """
{{ value|default:0 }}
"""
-PLATFORM_ACTIONS = """
-
-
-
-{% if perms.dcim.change_platform %}
-
-{% endif %}
-"""
-
STATUS_LABEL = """
{{ record.get_status_display }}
"""
@@ -169,20 +140,17 @@ POWERPANEL_POWERFEED_COUNT = """
{{ value }}
"""
+INTERFACE_IPADDRESSES = """
+{% for ip in record.ip_addresses.unrestricted %}
+ {{ ip }}
+{% endfor %}
+"""
-def get_component_template_actions(model_name):
- return """
- {{% if perms.dcim.change_{model_name} %}}
-
-
-
- {{% endif %}}
- {{% if perms.dcim.delete_{model_name} %}}
-
-
-
- {{% endif %}}
- """.format(model_name=model_name).strip()
+INTERFACE_TAGGED_VLANS = """
+{% for vlan in record.tagged_vlans.unrestricted %}
+ {{ vlan }}
+{% endfor %}
+"""
#
@@ -198,11 +166,7 @@ class RegionTable(BaseTable):
site_count = tables.Column(
verbose_name='Sites'
)
- actions = tables.TemplateColumn(
- template_code=REGION_ACTIONS,
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
- )
+ actions = ButtonsColumn(Region)
class Meta(BaseTable.Meta):
model = Region
@@ -254,16 +218,15 @@ class RackGroupTable(BaseTable):
)
site = tables.LinkColumn(
viewname='dcim:site',
- args=[Accessor('site.slug')],
+ args=[Accessor('site__slug')],
verbose_name='Site'
)
rack_count = tables.Column(
verbose_name='Racks'
)
- actions = tables.TemplateColumn(
- template_code=RACKGROUP_ACTIONS,
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+ actions = ButtonsColumn(
+ model=RackGroup,
+ prepend_template=RACKGROUP_ELEVATIONS
)
class Meta(BaseTable.Meta):
@@ -281,11 +244,7 @@ class RackRoleTable(BaseTable):
name = tables.Column(linkify=True)
rack_count = tables.Column(verbose_name='Racks')
color = tables.TemplateColumn(COLOR_LABEL)
- actions = tables.TemplateColumn(
- template_code=RACKROLE_ACTIONS,
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
- )
+ actions = ButtonsColumn(RackRole)
class Meta(BaseTable.Meta):
model = RackRole
@@ -304,7 +263,7 @@ class RackTable(BaseTable):
)
site = tables.LinkColumn(
viewname='dcim:site',
- args=[Accessor('site.slug')]
+ args=[Accessor('site__slug')]
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
@@ -363,37 +322,34 @@ class RackDetailTable(RackTable):
class RackReservationTable(BaseTable):
pk = ToggleColumn()
- reservation = tables.LinkColumn(
- viewname='dcim:rackreservation',
- args=[Accessor('pk')],
- accessor='pk'
+ reservation = tables.Column(
+ accessor='pk',
+ linkify=True
)
- site = tables.LinkColumn(
- viewname='dcim:site',
- accessor=Accessor('rack.site'),
- args=[Accessor('rack.site.slug')],
+ site = tables.Column(
+ accessor=Accessor('rack__site'),
+ linkify=True
)
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
- rack = tables.LinkColumn(
- viewname='dcim:rack',
- args=[Accessor('rack.pk')]
+ rack = tables.Column(
+ linkify=True
)
unit_list = tables.Column(
orderable=False,
verbose_name='Units'
)
- actions = tables.TemplateColumn(
- template_code=RACKRESERVATION_ACTIONS,
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+ tags = TagColumn(
+ url_name='dcim:rackreservation_list'
)
+ actions = ButtonsColumn(RackReservation)
class Meta(BaseTable.Meta):
model = RackReservation
fields = (
- 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions',
+ 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
+ 'actions',
)
default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
@@ -417,11 +373,7 @@ class ManufacturerTable(BaseTable):
verbose_name='Platforms'
)
slug = tables.Column()
- actions = tables.TemplateColumn(
- template_code=MANUFACTURER_ACTIONS,
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
- )
+ actions = ButtonsColumn(Manufacturer, pk_field='slug')
class Meta(BaseTable.Meta):
model = Manufacturer
@@ -436,9 +388,8 @@ class ManufacturerTable(BaseTable):
class DeviceTypeTable(BaseTable):
pk = ToggleColumn()
- model = tables.LinkColumn(
- viewname='dcim:devicetype',
- args=[Accessor('pk')],
+ model = tables.Column(
+ linkify=True,
verbose_name='Device Type'
)
is_full_depth = BooleanColumn(
@@ -467,234 +418,112 @@ class DeviceTypeTable(BaseTable):
# Device type components
#
-class ConsolePortTemplateTable(BaseTable):
+class ComponentTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',)
)
- actions = tables.TemplateColumn(
- template_code=get_component_template_actions('consoleporttemplate'),
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+
+
+class ConsolePortTemplateTable(ComponentTemplateTable):
+ actions = ButtonsColumn(
+ model=ConsolePortTemplate,
+ buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = ConsolePortTemplate
- fields = ('pk', 'name', 'type', 'actions')
+ fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
-class ConsolePortImportTable(BaseTable):
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
- )
-
- class Meta(BaseTable.Meta):
- model = ConsolePort
- fields = ('device', 'name', 'description')
- empty_text = False
-
-
-class ConsoleServerPortTemplateTable(BaseTable):
- pk = ToggleColumn()
- name = tables.Column(
- order_by=('_name',)
- )
- actions = tables.TemplateColumn(
- template_code=get_component_template_actions('consoleserverporttemplate'),
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+class ConsoleServerPortTemplateTable(ComponentTemplateTable):
+ actions = ButtonsColumn(
+ model=ConsoleServerPortTemplate,
+ buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = ConsoleServerPortTemplate
- fields = ('pk', 'name', 'type', 'actions')
+ fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
-class ConsoleServerPortImportTable(BaseTable):
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
- )
-
- class Meta(BaseTable.Meta):
- model = ConsoleServerPort
- fields = ('device', 'name', 'description')
- empty_text = False
-
-
-class PowerPortTemplateTable(BaseTable):
- pk = ToggleColumn()
- name = tables.Column(
- order_by=('_name',)
- )
- actions = tables.TemplateColumn(
- template_code=get_component_template_actions('powerporttemplate'),
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+class PowerPortTemplateTable(ComponentTemplateTable):
+ actions = ButtonsColumn(
+ model=PowerPortTemplate,
+ buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = PowerPortTemplate
- fields = ('pk', 'name', 'type', 'maximum_draw', 'allocated_draw', 'actions')
+ fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
empty_text = "None"
-class PowerPortImportTable(BaseTable):
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
- )
-
- class Meta(BaseTable.Meta):
- model = PowerPort
- fields = ('device', 'name', 'description', 'maximum_draw', 'allocated_draw')
- empty_text = False
-
-
-class PowerOutletTemplateTable(BaseTable):
- pk = ToggleColumn()
- name = tables.Column(
- order_by=('_name',)
- )
- actions = tables.TemplateColumn(
- template_code=get_component_template_actions('poweroutlettemplate'),
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+class PowerOutletTemplateTable(ComponentTemplateTable):
+ actions = ButtonsColumn(
+ model=PowerOutletTemplate,
+ buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = PowerOutletTemplate
- fields = ('pk', 'name', 'type', 'power_port', 'feed_leg', 'actions')
+ fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
empty_text = "None"
-class PowerOutletImportTable(BaseTable):
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
+class InterfaceTemplateTable(ComponentTemplateTable):
+ mgmt_only = BooleanColumn(
+ verbose_name='Management Only'
)
-
- class Meta(BaseTable.Meta):
- model = PowerOutlet
- fields = ('device', 'name', 'description', 'power_port', 'feed_leg')
- empty_text = False
-
-
-class InterfaceTemplateTable(BaseTable):
- pk = ToggleColumn()
- mgmt_only = tables.TemplateColumn(
- template_code="{% if value %}OOB Management{% endif %}"
- )
- actions = tables.TemplateColumn(
- template_code=get_component_template_actions('interfacetemplate'),
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+ actions = ButtonsColumn(
+ model=InterfaceTemplate,
+ buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = InterfaceTemplate
- fields = ('pk', 'name', 'mgmt_only', 'type', 'actions')
+ fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
empty_text = "None"
-class InterfaceImportTable(BaseTable):
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
- )
- virtual_machine = tables.LinkColumn(
- viewname='virtualization:virtualmachine',
- args=[Accessor('virtual_machine.pk')],
- verbose_name='Virtual Machine'
- )
-
- class Meta(BaseTable.Meta):
- model = Interface
- fields = (
- 'device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu',
- 'mgmt_only', 'mode',
- )
- empty_text = False
-
-
-class FrontPortTemplateTable(BaseTable):
- pk = ToggleColumn()
- name = tables.Column(
- order_by=('_name',)
- )
+class FrontPortTemplateTable(ComponentTemplateTable):
rear_port_position = tables.Column(
verbose_name='Position'
)
- actions = tables.TemplateColumn(
- template_code=get_component_template_actions('frontporttemplate'),
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+ actions = ButtonsColumn(
+ model=FrontPortTemplate,
+ buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = FrontPortTemplate
- fields = ('pk', 'name', 'type', 'rear_port', 'rear_port_position', 'actions')
+ fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions')
empty_text = "None"
-class FrontPortImportTable(BaseTable):
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
- )
-
- class Meta(BaseTable.Meta):
- model = FrontPort
- fields = ('device', 'name', 'description', 'type', 'rear_port', 'rear_port_position')
- empty_text = False
-
-
-class RearPortTemplateTable(BaseTable):
- pk = ToggleColumn()
- name = tables.Column(
- order_by=('_name',)
- )
- actions = tables.TemplateColumn(
- template_code=get_component_template_actions('rearporttemplate'),
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+class RearPortTemplateTable(ComponentTemplateTable):
+ actions = ButtonsColumn(
+ model=RearPortTemplate,
+ buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = RearPortTemplate
- fields = ('pk', 'name', 'type', 'positions', 'actions')
+ fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions')
empty_text = "None"
-class RearPortImportTable(BaseTable):
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
- )
-
- class Meta(BaseTable.Meta):
- model = RearPort
- fields = ('device', 'name', 'description', 'type', 'position')
- empty_text = False
-
-
-class DeviceBayTemplateTable(BaseTable):
- pk = ToggleColumn()
- name = tables.Column(
- order_by=('_name',)
- )
- actions = tables.TemplateColumn(
- template_code=get_component_template_actions('devicebaytemplate'),
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
+class DeviceBayTemplateTable(ComponentTemplateTable):
+ actions = ButtonsColumn(
+ model=DeviceBayTemplate,
+ buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):
model = DeviceBayTemplate
- fields = ('pk', 'name', 'actions')
+ fields = ('pk', 'name', 'label', 'description', 'actions')
empty_text = "None"
@@ -717,11 +546,7 @@ class DeviceRoleTable(BaseTable):
verbose_name='Label'
)
vm_role = BooleanColumn()
- actions = tables.TemplateColumn(
- template_code=DEVICEROLE_ACTIONS,
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
- )
+ actions = ButtonsColumn(DeviceRole, pk_field='slug')
class Meta(BaseTable.Meta):
model = DeviceRole
@@ -743,11 +568,7 @@ class PlatformTable(BaseTable):
template_code=PLATFORM_VM_COUNT,
verbose_name='VMs'
)
- actions = tables.TemplateColumn(
- template_code=PLATFORM_ACTIONS,
- attrs={'td': {'class': 'text-right noprint'}},
- verbose_name=''
- )
+ actions = ButtonsColumn(Platform, pk_field='slug')
class Meta(BaseTable.Meta):
model = Platform
@@ -776,20 +597,18 @@ class DeviceTable(BaseTable):
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
- site = tables.LinkColumn(
- viewname='dcim:site',
- args=[Accessor('site.slug')]
+ site = tables.Column(
+ linkify=True
)
- rack = tables.LinkColumn(
- viewname='dcim:rack',
- args=[Accessor('rack.pk')]
+ rack = tables.Column(
+ linkify=True
)
device_role = ColoredLabelColumn(
verbose_name='Role'
)
device_type = tables.LinkColumn(
viewname='dcim:devicetype',
- args=[Accessor('device_type.pk')],
+ args=[Accessor('device_type__pk')],
verbose_name='Type',
text=lambda record: record.device_type.display_name
)
@@ -798,23 +617,21 @@ class DeviceTable(BaseTable):
orderable=False,
verbose_name='IP Address'
)
- primary_ip4 = tables.LinkColumn(
- viewname='ipam:ipaddress',
- args=[Accessor('primary_ip4.pk')],
+ primary_ip4 = tables.Column(
+ linkify=True,
verbose_name='IPv4 Address'
)
- primary_ip6 = tables.LinkColumn(
- viewname='ipam:ipaddress',
- args=[Accessor('primary_ip6.pk')],
+ primary_ip6 = tables.Column(
+ linkify=True,
verbose_name='IPv6 Address'
)
cluster = tables.LinkColumn(
viewname='virtualization:cluster',
- args=[Accessor('cluster.pk')]
+ args=[Accessor('cluster__pk')]
)
virtual_chassis = tables.LinkColumn(
viewname='dcim:virtualchassis',
- args=[Accessor('virtual_chassis.pk')]
+ args=[Accessor('virtual_chassis__pk')]
)
vc_position = tables.Column(
verbose_name='VC Position'
@@ -848,13 +665,11 @@ class DeviceImportTable(BaseTable):
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)
- site = tables.LinkColumn(
- viewname='dcim:site',
- args=[Accessor('site.slug')]
+ site = tables.Column(
+ linkify=True
)
- rack = tables.LinkColumn(
- viewname='dcim:rack',
- args=[Accessor('rack.pk')]
+ rack = tables.Column(
+ linkify=True
)
device_role = tables.Column(
verbose_name='Role'
@@ -873,153 +688,124 @@ class DeviceImportTable(BaseTable):
# Device components
#
-class DeviceComponentDetailTable(BaseTable):
+class DeviceComponentTable(BaseTable):
pk = ToggleColumn()
- name = tables.Column(order_by=('_name',))
- cable = tables.LinkColumn()
+ device = tables.Column(
+ linkify=True
+ )
+ name = tables.Column(
+ linkify=True,
+ order_by=('_name',)
+ )
+ cable = tables.Column(
+ linkify=True
+ )
class Meta(BaseTable.Meta):
order_by = ('device', 'name')
- fields = ('pk', 'device', 'name', 'type', 'description', 'cable')
- sequence = ('pk', 'device', 'name', 'type', 'description', 'cable')
-class ConsolePortTable(BaseTable):
- name = tables.Column(order_by=('_name',))
+class ConsolePortTable(DeviceComponentTable):
- class Meta(BaseTable.Meta):
+ class Meta(DeviceComponentTable.Meta):
model = ConsolePort
- fields = ('name', 'type')
+ fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
+ default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
-class ConsolePortDetailTable(DeviceComponentDetailTable):
- device = tables.LinkColumn()
+class ConsoleServerPortTable(DeviceComponentTable):
- class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta):
- pass
-
-
-class ConsoleServerPortTable(BaseTable):
- name = tables.Column(order_by=('_name',))
-
- class Meta(BaseTable.Meta):
+ class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
- fields = ('name', 'description')
+ fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
+ default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
-class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
- device = tables.LinkColumn()
+class PowerPortTable(DeviceComponentTable):
- class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta):
- pass
-
-
-class PowerPortTable(BaseTable):
- name = tables.Column(order_by=('_name',))
-
- class Meta(BaseTable.Meta):
+ class Meta(DeviceComponentTable.Meta):
model = PowerPort
- fields = ('name', 'type')
+ fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable')
+ default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
-class PowerPortDetailTable(DeviceComponentDetailTable):
- device = tables.LinkColumn()
+class PowerOutletTable(DeviceComponentTable):
- class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta):
- pass
-
-
-class PowerOutletTable(BaseTable):
- name = tables.Column(order_by=('_name',))
-
- class Meta(BaseTable.Meta):
+ class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
- fields = ('name', 'type', 'description')
+ fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable')
+ default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
-class PowerOutletDetailTable(DeviceComponentDetailTable):
- device = tables.LinkColumn()
-
- class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta):
- pass
-
-
-class InterfaceTable(BaseTable):
-
- class Meta(BaseTable.Meta):
- model = Interface
- fields = ('name', 'type', 'lag', 'enabled', 'mgmt_only', 'description')
-
-
-class InterfaceDetailTable(DeviceComponentDetailTable):
- parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
- name = tables.LinkColumn()
+class BaseInterfaceTable(BaseTable):
enabled = BooleanColumn()
-
- class Meta(InterfaceTable.Meta):
- order_by = ('parent', 'name')
- fields = ('pk', 'parent', 'name', 'enabled', 'type', 'mac_address', 'description', 'cable')
- default_columns = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
+ ip_addresses = tables.TemplateColumn(
+ template_code=INTERFACE_IPADDRESSES,
+ orderable=False,
+ verbose_name='IP Addresses'
+ )
+ untagged_vlan = tables.Column(linkify=True)
+ tagged_vlans = tables.TemplateColumn(
+ template_code=INTERFACE_TAGGED_VLANS,
+ orderable=False,
+ verbose_name='Tagged VLANs'
+ )
-class FrontPortTable(BaseTable):
- name = tables.Column(order_by=('_name',))
+class InterfaceTable(DeviceComponentTable, BaseInterfaceTable):
- class Meta(BaseTable.Meta):
+ class Meta(DeviceComponentTable.Meta):
+ model = Interface
+ fields = (
+ 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
+ 'description', 'cable', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
+ )
+ default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
+
+
+class FrontPortTable(DeviceComponentTable):
+ rear_port_position = tables.Column(
+ verbose_name='Position'
+ )
+
+ class Meta(DeviceComponentTable.Meta):
model = FrontPort
- fields = ('name', 'type', 'rear_port', 'rear_port_position', 'description')
- empty_text = "None"
+ fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable')
+ default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
-class FrontPortDetailTable(DeviceComponentDetailTable):
- device = tables.LinkColumn()
+class RearPortTable(DeviceComponentTable):
- class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta):
- pass
-
-
-class RearPortTable(BaseTable):
- name = tables.Column(order_by=('_name',))
-
- class Meta(BaseTable.Meta):
+ class Meta(DeviceComponentTable.Meta):
model = RearPort
- fields = ('name', 'type', 'positions', 'description')
- empty_text = "None"
+ fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable')
+ default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
-class RearPortDetailTable(DeviceComponentDetailTable):
- device = tables.LinkColumn()
+class DeviceBayTable(DeviceComponentTable):
+ installed_device = tables.Column(
+ linkify=True
+ )
- class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta):
- pass
-
-
-class DeviceBayTable(BaseTable):
- name = tables.Column(order_by=('_name',))
-
- class Meta(BaseTable.Meta):
+ class Meta(DeviceComponentTable.Meta):
model = DeviceBay
- fields = ('name', 'description')
+ fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
+ default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
-class DeviceBayDetailTable(DeviceComponentDetailTable):
- device = tables.LinkColumn()
- installed_device = tables.LinkColumn()
+class InventoryItemTable(DeviceComponentTable):
+ manufacturer = tables.Column(
+ linkify=True
+ )
+ discovered = BooleanColumn()
- class Meta(DeviceBayTable.Meta):
- fields = ('pk', 'name', 'device', 'installed_device', 'description')
- sequence = ('pk', 'name', 'device', 'installed_device', 'description')
- exclude = ('cable',)
-
-
-class DeviceBayImportTable(BaseTable):
- device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
- installed_device = tables.LinkColumn('dcim:device', args=[Accessor('installed_device.pk')], verbose_name='Installed Device')
-
- class Meta(BaseTable.Meta):
- model = DeviceBay
- fields = ('device', 'name', 'installed_device', 'description')
- empty_text = False
+ class Meta(DeviceComponentTable.Meta):
+ model = InventoryItem
+ fields = (
+ 'pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+ 'discovered',
+ )
+ default_columns = ('pk', 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
#
@@ -1028,9 +814,8 @@ class DeviceBayImportTable(BaseTable):
class CableTable(BaseTable):
pk = ToggleColumn()
- id = tables.LinkColumn(
- viewname='dcim:cable',
- args=[Accessor('pk')],
+ id = tables.Column(
+ linkify=True,
verbose_name='ID'
)
termination_a_parent = tables.TemplateColumn(
@@ -1063,12 +848,15 @@ class CableTable(BaseTable):
order_by='_abs_length'
)
color = ColorColumn()
+ tags = TagColumn(
+ url_name='dcim:cable_list'
+ )
class Meta(BaseTable.Meta):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
- 'status', 'type', 'color', 'length',
+ 'status', 'type', 'color', 'length', 'tags',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
@@ -1083,20 +871,20 @@ class CableTable(BaseTable):
class ConsoleConnectionTable(BaseTable):
console_server = tables.LinkColumn(
viewname='dcim:device',
- accessor=Accessor('connected_endpoint.device'),
- args=[Accessor('connected_endpoint.device.pk')],
+ accessor=Accessor('connected_endpoint__device'),
+ args=[Accessor('connected_endpoint__device__pk')],
verbose_name='Console Server'
)
connected_endpoint = tables.Column(
verbose_name='Port'
)
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
+ device = tables.Column(
+ linkify=True
)
name = tables.Column(
verbose_name='Console Port'
)
+ connection_status = BooleanColumn()
class Meta(BaseTable.Meta):
model = ConsolePort
@@ -1106,8 +894,8 @@ class ConsoleConnectionTable(BaseTable):
class PowerConnectionTable(BaseTable):
pdu = tables.LinkColumn(
viewname='dcim:device',
- accessor=Accessor('connected_endpoint.device'),
- args=[Accessor('connected_endpoint.device.pk')],
+ accessor=Accessor('connected_endpoint__device'),
+ args=[Accessor('connected_endpoint__device__pk')],
order_by='_connected_poweroutlet__device',
verbose_name='PDU'
)
@@ -1115,9 +903,8 @@ class PowerConnectionTable(BaseTable):
accessor=Accessor('_connected_poweroutlet'),
verbose_name='Outlet'
)
- device = tables.LinkColumn(
- viewname='dcim:device',
- args=[Accessor('device.pk')]
+ device = tables.Column(
+ linkify=True
)
name = tables.Column(
verbose_name='Power Port'
@@ -1132,7 +919,7 @@ class InterfaceConnectionTable(BaseTable):
device_a = tables.LinkColumn(
viewname='dcim:device',
accessor=Accessor('device'),
- args=[Accessor('device.pk')],
+ args=[Accessor('device__pk')],
verbose_name='Device A'
)
interface_a = tables.LinkColumn(
@@ -1143,14 +930,14 @@ class InterfaceConnectionTable(BaseTable):
)
device_b = tables.LinkColumn(
viewname='dcim:device',
- accessor=Accessor('_connected_interface.device'),
- args=[Accessor('_connected_interface.device.pk')],
+ accessor=Accessor('_connected_interface__device'),
+ args=[Accessor('_connected_interface__device__pk')],
verbose_name='Device B'
)
interface_b = tables.LinkColumn(
viewname='dcim:interface',
accessor=Accessor('_connected_interface'),
- args=[Accessor('_connected_interface.pk')],
+ args=[Accessor('_connected_interface__pk')],
verbose_name='Interface B'
)
@@ -1161,29 +948,6 @@ class InterfaceConnectionTable(BaseTable):
)
-#
-# InventoryItems
-#
-
-class InventoryItemTable(BaseTable):
- pk = ToggleColumn()
- device = tables.LinkColumn(
- viewname='dcim:device_inventory',
- args=[Accessor('device.pk')]
- )
- manufacturer = tables.Column(
- accessor=Accessor('manufacturer')
- )
- discovered = BooleanColumn()
-
- class Meta(BaseTable.Meta):
- model = InventoryItem
- fields = (
- 'pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered'
- )
- default_columns = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag')
-
-
#
# Virtual chassis
#
@@ -1191,7 +955,9 @@ class InventoryItemTable(BaseTable):
class VirtualChassisTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
- accessor=Accessor('master__name'),
+ linkify=True
+ )
+ master = tables.Column(
linkify=True
)
member_count = tables.Column(
@@ -1203,8 +969,8 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta):
model = VirtualChassis
- fields = ('pk', 'name', 'domain', 'member_count', 'tags')
- default_columns = ('pk', 'name', 'domain', 'member_count')
+ fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
+ default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
#
@@ -1216,16 +982,19 @@ class PowerPanelTable(BaseTable):
name = tables.LinkColumn()
site = tables.LinkColumn(
viewname='dcim:site',
- args=[Accessor('site.slug')]
+ args=[Accessor('site__slug')]
)
powerfeed_count = tables.TemplateColumn(
template_code=POWERPANEL_POWERFEED_COUNT,
verbose_name='Feeds'
)
+ tags = TagColumn(
+ url_name='dcim:powerpanel_list'
+ )
class Meta(BaseTable.Meta):
model = PowerPanel
- fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
+ fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags')
default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
@@ -1236,13 +1005,11 @@ class PowerPanelTable(BaseTable):
class PowerFeedTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
- power_panel = tables.LinkColumn(
- viewname='dcim:powerpanel',
- args=[Accessor('power_panel.pk')],
+ power_panel = tables.Column(
+ linkify=True
)
- rack = tables.LinkColumn(
- viewname='dcim:rack',
- args=[Accessor('rack.pk')]
+ rack = tables.Column(
+ linkify=True
)
status = tables.TemplateColumn(
template_code=STATUS_LABEL
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index 4af82170a..c3ffecdff 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -1,5 +1,6 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
from django.urls import reverse
from rest_framework import status
@@ -28,9 +29,46 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
+class Mixins:
+
+ class ComponentTraceMixin(APITestCase):
+ peer_termination_type = None
+
+ def test_trace(self):
+ """
+ Test tracing a device component's attached cable.
+ """
+ obj = self.model.objects.first()
+ peer_device = Device.objects.create(
+ site=Site.objects.first(),
+ device_type=DeviceType.objects.first(),
+ device_role=DeviceRole.objects.first(),
+ name='Peer Device'
+ )
+ if self.peer_termination_type is None:
+ raise NotImplementedError("Test case must set peer_termination_type")
+ peer_obj = self.peer_termination_type.objects.create(
+ device=peer_device,
+ name='Peer Termination'
+ )
+ cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1')
+ cable.save()
+
+ self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
+ url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk})
+ response = self.client.get(url, **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(len(response.data), 1)
+ segment1 = response.data[0]
+ self.assertEqual(segment1[0]['name'], obj.name)
+ self.assertEqual(segment1[1]['label'], cable.label)
+ self.assertEqual(segment1[2]['name'], peer_obj.name)
+
+
class RegionTest(APIViewTestCases.APIViewTestCase):
model = Region
- brief_fields = ['id', 'name', 'site_count', 'slug', 'url']
+ brief_fields = ['_depth', 'id', 'name', 'site_count', 'slug', 'url']
create_data = [
{
'name': 'Region 4',
@@ -94,6 +132,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
},
]
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_site_graphs(self):
"""
Test retrieval of Graphs assigned to Sites.
@@ -106,6 +145,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
)
Graph.objects.bulk_create(graphs)
+ self.add_permissions('dcim.view_site')
url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk})
response = self.client.get(url, **self.header)
@@ -115,7 +155,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
class RackGroupTest(APIViewTestCases.APIViewTestCase):
model = RackGroup
- brief_fields = ['id', 'name', 'rack_count', 'slug', 'url']
+ brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url']
@classmethod
def setUpTestData(cls):
@@ -241,48 +281,35 @@ class RackTest(APIViewTestCases.APIViewTestCase):
},
]
- # TODO: Document this test
- def test_get_elevation_rack_units(self):
- rack = Rack.objects.first()
-
- url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 13)
-
- url = '{}?q=U3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 11)
-
- url = '{}?q=10'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 1)
-
- url = '{}?q=U20'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 1)
-
def test_get_rack_elevation(self):
"""
GET a single rack elevation.
"""
rack = Rack.objects.first()
+ self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
- response = self.client.get(url, **self.header)
+ # Retrieve all units
+ response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 42)
+ # Search for specific units
+ response = self.client.get(f'{url}?q=3', **self.header)
+ self.assertEqual(response.data['count'], 13)
+ response = self.client.get(f'{url}?q=U3', **self.header)
+ self.assertEqual(response.data['count'], 11)
+ response = self.client.get(f'{url}?q=U10', **self.header)
+ self.assertEqual(response.data['count'], 1)
+
def test_get_rack_elevation_svg(self):
"""
GET a single rack elevation in SVG format.
"""
rack = Rack.objects.first()
+ self.add_permissions('dcim.view_rack')
url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
- response = self.client.get(url, **self.header)
+ response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
@@ -293,9 +320,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
-
user = User.objects.create(username='user1', is_active=True)
-
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
cls.racks = (
@@ -877,6 +902,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
},
]
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_device_graphs(self):
"""
Test retrieval of Graphs assigned to Devices.
@@ -889,6 +915,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
)
Graph.objects.bulk_create(graphs)
+ self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk})
response = self.client.get(url, **self.header)
@@ -899,6 +926,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
"""
Check that config context data is included by default in the devices list.
"""
+ self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
response = self.client.get(url, **self.header)
@@ -908,6 +936,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
"""
Check that config context data can be excluded by passing ?exclude=config_context.
"""
+ self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-list') + '?exclude=config_context'
response = self.client.get(url, **self.header)
@@ -925,15 +954,17 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
'name': device.name,
}
+ self.add_permissions('dcim.add_device')
url = reverse('dcim-api:device-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
-class ConsolePortTest(APIViewTestCases.APIViewTestCase):
+class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
+ peer_termination_type = ConsoleServerPort
@classmethod
def setUpTestData(cls):
@@ -965,38 +996,11 @@ class ConsolePortTest(APIViewTestCases.APIViewTestCase):
},
]
- def test_trace_consoleport(self):
- """
- Test tracing a ConsolePort cable.
- """
- consoleport = ConsolePort.objects.first()
- peer_device = Device.objects.create(
- site=Site.objects.first(),
- device_type=DeviceType.objects.first(),
- device_role=DeviceRole.objects.first(),
- name='Peer Device'
- )
- consoleserverport = ConsoleServerPort.objects.create(
- device=peer_device,
- name='Console Server Port 1'
- )
- cable = Cable(termination_a=consoleport, termination_b=consoleserverport, label='Cable 1')
- cable.save()
- url = reverse('dcim-api:consoleport-trace', kwargs={'pk': consoleport.pk})
- response = self.client.get(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
- segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], consoleport.name)
- self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], consoleserverport.name)
-
-
-class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
+class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsoleServerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
+ peer_termination_type = ConsolePort
@classmethod
def setUpTestData(cls):
@@ -1028,38 +1032,11 @@ class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
},
]
- def test_trace_consoleserverport(self):
- """
- Test tracing a ConsoleServerPort cable.
- """
- consoleserverport = ConsoleServerPort.objects.first()
- peer_device = Device.objects.create(
- site=Site.objects.first(),
- device_type=DeviceType.objects.first(),
- device_role=DeviceRole.objects.first(),
- name='Peer Device'
- )
- consoleport = ConsolePort.objects.create(
- device=peer_device,
- name='Console Port 1'
- )
- cable = Cable(termination_a=consoleserverport, termination_b=consoleport, label='Cable 1')
- cable.save()
- url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': consoleserverport.pk})
- response = self.client.get(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
- segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], consoleserverport.name)
- self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], consoleport.name)
-
-
-class PowerPortTest(APIViewTestCases.APIViewTestCase):
+class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
+ peer_termination_type = PowerOutlet
@classmethod
def setUpTestData(cls):
@@ -1091,38 +1068,11 @@ class PowerPortTest(APIViewTestCases.APIViewTestCase):
},
]
- def test_trace_powerport(self):
- """
- Test tracing a PowerPort cable.
- """
- powerport = PowerPort.objects.first()
- peer_device = Device.objects.create(
- site=Site.objects.first(),
- device_type=DeviceType.objects.first(),
- device_role=DeviceRole.objects.first(),
- name='Peer Device'
- )
- poweroutlet = PowerOutlet.objects.create(
- device=peer_device,
- name='Power Outlet 1'
- )
- cable = Cable(termination_a=powerport, termination_b=poweroutlet, label='Cable 1')
- cable.save()
- url = reverse('dcim-api:powerport-trace', kwargs={'pk': powerport.pk})
- response = self.client.get(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
- segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], powerport.name)
- self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], poweroutlet.name)
-
-
-class PowerOutletTest(APIViewTestCases.APIViewTestCase):
+class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerOutlet
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
+ peer_termination_type = PowerPort
@classmethod
def setUpTestData(cls):
@@ -1154,38 +1104,11 @@ class PowerOutletTest(APIViewTestCases.APIViewTestCase):
},
]
- def test_trace_poweroutlet(self):
- """
- Test tracing a PowerOutlet cable.
- """
- poweroutlet = PowerOutlet.objects.first()
- peer_device = Device.objects.create(
- site=Site.objects.first(),
- device_type=DeviceType.objects.first(),
- device_role=DeviceRole.objects.first(),
- name='Peer Device'
- )
- powerport = PowerPort.objects.create(
- device=peer_device,
- name='Power Port 1'
- )
- cable = Cable(termination_a=poweroutlet, termination_b=powerport, label='Cable 1')
- cable.save()
- url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': poweroutlet.pk})
- response = self.client.get(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
- segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], poweroutlet.name)
- self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], powerport.name)
-
-
-class InterfaceTest(APIViewTestCases.APIViewTestCase):
+class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = Interface
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
+ peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@@ -1236,6 +1159,7 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
},
]
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_interface_graphs(self):
"""
Test retrieval of Graphs assigned to Devices.
@@ -1248,44 +1172,18 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
)
Graph.objects.bulk_create(graphs)
+ self.add_permissions('dcim.view_interface')
url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
- def test_trace_interface(self):
- """
- Test tracing an Interface cable.
- """
- interface_a = Interface.objects.first()
- peer_device = Device.objects.create(
- site=Site.objects.first(),
- device_type=DeviceType.objects.first(),
- device_role=DeviceRole.objects.first(),
- name='Peer Device'
- )
- interface_b = Interface.objects.create(
- device=peer_device,
- name='Interface X'
- )
- cable = Cable(termination_a=interface_a, termination_b=interface_b, label='Cable 1')
- cable.save()
- url = reverse('dcim-api:interface-trace', kwargs={'pk': interface_a.pk})
- response = self.client.get(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
- segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], interface_a.name)
- self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], interface_b.name)
-
-
-class FrontPortTest(APIViewTestCases.APIViewTestCase):
+class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = FrontPort
brief_fields = ['cable', 'device', 'id', 'name', 'url']
+ peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@@ -1336,38 +1234,11 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
},
]
- def test_trace_frontport(self):
- """
- Test tracing a FrontPort cable.
- """
- frontport = FrontPort.objects.first()
- peer_device = Device.objects.create(
- site=Site.objects.first(),
- device_type=DeviceType.objects.first(),
- device_role=DeviceRole.objects.first(),
- name='Peer Device'
- )
- interface = Interface.objects.create(
- device=peer_device,
- name='Interface X'
- )
- cable = Cable(termination_a=frontport, termination_b=interface, label='Cable 1')
- cable.save()
- url = reverse('dcim-api:frontport-trace', kwargs={'pk': frontport.pk})
- response = self.client.get(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
- segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], frontport.name)
- self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], interface.name)
-
-
-class RearPortTest(APIViewTestCases.APIViewTestCase):
+class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = RearPort
brief_fields = ['cable', 'device', 'id', 'name', 'url']
+ peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@@ -1402,34 +1273,6 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
},
]
- def test_trace_rearport(self):
- """
- Test tracing a RearPort cable.
- """
- rearport = RearPort.objects.first()
- peer_device = Device.objects.create(
- site=Site.objects.first(),
- device_type=DeviceType.objects.first(),
- device_role=DeviceRole.objects.first(),
- name='Peer Device'
- )
- interface = Interface.objects.create(
- device=peer_device,
- name='Interface X'
- )
- cable = Cable(termination_a=rearport, termination_b=interface, label='Cable 1')
- cable.save()
-
- url = reverse('dcim-api:rearport-trace', kwargs={'pk': rearport.pk})
- response = self.client.get(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(len(response.data), 1)
- segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], rearport.name)
- self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], interface.name)
-
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay
@@ -1635,6 +1478,7 @@ class ConnectionTest(APITestCase):
'termination_b_id': consoleserverport1.pk,
}
+ self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -1673,6 +1517,7 @@ class ConnectionTest(APITestCase):
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
+ self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Console port to panel1 front
@@ -1728,6 +1573,7 @@ class ConnectionTest(APITestCase):
'termination_b_id': poweroutlet1.pk,
}
+ self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -1763,6 +1609,7 @@ class ConnectionTest(APITestCase):
'termination_b_id': interface2.pk,
}
+ self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -1801,6 +1648,7 @@ class ConnectionTest(APITestCase):
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
+ self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Interface1 to panel1 front
@@ -1865,6 +1713,7 @@ class ConnectionTest(APITestCase):
'termination_b_id': circuittermination1.pk,
}
+ self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -1912,6 +1761,7 @@ class ConnectionTest(APITestCase):
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
+ self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Interface to panel1 front
@@ -1996,7 +1846,7 @@ class ConnectedDeviceTest(APITestCase):
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
model = VirtualChassis
- brief_fields = ['id', 'master', 'member_count', 'url']
+ brief_fields = ['id', 'master', 'member_count', 'name', 'url']
@classmethod
def setUpTestData(cls):
@@ -2015,6 +1865,9 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
Device(name='Device 7', device_type=devicetype, device_role=devicerole, site=site),
Device(name='Device 8', device_type=devicetype, device_role=devicerole, site=site),
Device(name='Device 9', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 10', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 11', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 12', device_type=devicetype, device_role=devicerole, site=site),
)
Device.objects.bulk_create(devices)
@@ -2028,35 +1881,39 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
)
Interface.objects.bulk_create(interfaces)
- # Create two VirtualChassis with three members each
+ # Create three VirtualChassis with three members each
virtual_chassis = (
- VirtualChassis(master=devices[0], domain='domain-1'),
- VirtualChassis(master=devices[3], domain='domain-2'),
+ VirtualChassis(name='Virtual Chassis 1', master=devices[0], domain='domain-1'),
+ VirtualChassis(name='Virtual Chassis 2', master=devices[3], domain='domain-2'),
+ VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'),
)
VirtualChassis.objects.bulk_create(virtual_chassis)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
+ Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2)
+ Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3)
cls.update_data = {
- 'master': devices[1].pk,
+ 'name': 'Virtual Chassis X',
'domain': 'domain-x',
+ 'master': devices[1].pk,
}
cls.create_data = [
{
- 'master': devices[6].pk,
- 'domain': 'domain-3',
- },
- {
- 'master': devices[7].pk,
+ 'name': 'Virtual Chassis 4',
'domain': 'domain-4',
},
{
- 'master': devices[8].pk,
+ 'name': 'Virtual Chassis 5',
'domain': 'domain-5',
},
+ {
+ 'name': 'Virtual Chassis 6',
+ 'domain': 'domain-6',
+ },
]
@@ -2102,7 +1959,7 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
class PowerFeedTest(APIViewTestCases.APIViewTestCase):
model = PowerFeed
- brief_fields = ['id', 'name', 'url']
+ brief_fields = ['cable', 'id', 'name', 'url']
@classmethod
def setUpTestData(cls):
diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py
index 6c261f025..d4504d586 100644
--- a/netbox/dcim/tests/test_filters.py
+++ b/netbox/dcim/tests/test_filters.py
@@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase):
# Assign primary IPs for filtering
ipaddresses = (
- IPAddress(address='192.0.2.1/24', interface=interfaces[0]),
- IPAddress(address='192.0.2.2/24', interface=interfaces[1]),
+ IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
+ IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
)
IPAddress.objects.bulk_create(ipaddresses)
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])
diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py
index 29e741560..aadc2cbfc 100644
--- a/netbox/dcim/tests/test_forms.py
+++ b/netbox/dcim/tests/test_forms.py
@@ -116,3 +116,45 @@ class DeviceTestCase(TestCase):
# Check that the initial value for the cluster group is set automatically when assigning the cluster
self.assertEqual(test.initial['cluster_group'], cluster.group.pk)
+
+
+class LabelTestCase(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+ site = Site.objects.create(name='Site 2', slug='site-2')
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 2', slug='manufacturer-2')
+ cls.device_type = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=1
+ )
+ device_role = DeviceRole.objects.create(
+ name='Device Role 2', slug='device-role-2', color='ffff00'
+ )
+ cls.device = Device.objects.create(
+ name='Device 2', device_type=cls.device_type, device_role=device_role, site=site
+ )
+
+ def test_interface_label_count_valid(self):
+ """Test that a `label` can be generated for each generated `name` from `name_pattern` on InterfaceCreateForm"""
+ interface_data = {
+ 'device': self.device.pk,
+ 'name_pattern': 'eth[0-9]',
+ 'label_pattern': 'Interface[0-9]',
+ 'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
+ }
+ form = InterfaceCreateForm(interface_data)
+
+ self.assertTrue(form.is_valid())
+
+ def test_interface_label_count_mismatch(self):
+ """Test that a `label` cannot be generated for each generated `name` from `name_pattern` due to invalid `label_pattern` on InterfaceCreateForm"""
+ bad_interface_data = {
+ 'device': self.device.pk,
+ 'name_pattern': 'eth[0-9]',
+ 'label_pattern': 'Interface[0-1]',
+ 'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
+ }
+ form = InterfaceCreateForm(bad_interface_data)
+
+ self.assertFalse(form.is_valid())
+ self.assertIn('label_pattern', form.errors)
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index bd8ca6d47..066ea1b02 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -4,6 +4,7 @@ import pytz
import yaml
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
from django.urls import reverse
from netaddr import EUI
@@ -76,6 +77,8 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Site(name='Site 3', slug='site-3', region=regions[0]),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Site X',
'slug': 'site-x',
@@ -94,7 +97,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'contact_phone': '123-555-9999',
'contact_email': 'hank@stricklandpropane.com',
'comments': 'Test site',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -196,12 +199,15 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'rack': rack.pk,
'units': "10,11,12",
'user': user3.pk,
'tenant': None,
'description': 'Rack reservation',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -249,6 +255,8 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Rack(name='Rack 3', site=sites[0]),
))
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Rack X',
'facility_id': 'Facility X',
@@ -267,7 +275,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'comments': 'Some comments',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -321,7 +329,18 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
)
-class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Change base class to PrimaryObjectViewTestCase
+# Blocked by absence of bulk import view for DeviceTypes
+class DeviceTypeTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.GetObjectChangelogViewTestCase,
+ ViewTestCases.CreateObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = DeviceType
@classmethod
@@ -339,6 +358,8 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'manufacturer': manufacturers[1].pk,
'model': 'Device Type X',
@@ -348,7 +369,7 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'is_full_depth': True,
'subdevice_role': '', # CharField
'comments': 'Some comments',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -357,6 +378,7 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'is_full_depth': False,
}
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_objects(self):
"""
Custom import test for YAML-based imports (versus CSV)
@@ -460,45 +482,45 @@ device-bays:
self.assertEqual(dt.comments, 'test comment')
# Verify all of the components were created
- self.assertEqual(dt.consoleport_templates.count(), 3)
+ self.assertEqual(dt.consoleporttemplates.count(), 3)
cp1 = ConsolePortTemplate.objects.first()
self.assertEqual(cp1.name, 'Console Port 1')
self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9)
- self.assertEqual(dt.consoleserverport_templates.count(), 3)
+ self.assertEqual(dt.consoleserverporttemplates.count(), 3)
csp1 = ConsoleServerPortTemplate.objects.first()
self.assertEqual(csp1.name, 'Console Server Port 1')
self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45)
- self.assertEqual(dt.powerport_templates.count(), 3)
+ self.assertEqual(dt.powerporttemplates.count(), 3)
pp1 = PowerPortTemplate.objects.first()
self.assertEqual(pp1.name, 'Power Port 1')
self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14)
- self.assertEqual(dt.poweroutlet_templates.count(), 3)
+ self.assertEqual(dt.poweroutlettemplates.count(), 3)
po1 = PowerOutletTemplate.objects.first()
self.assertEqual(po1.name, 'Power Outlet 1')
self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13)
self.assertEqual(po1.power_port, pp1)
self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
- self.assertEqual(dt.interface_templates.count(), 3)
+ self.assertEqual(dt.interfacetemplates.count(), 3)
iface1 = InterfaceTemplate.objects.first()
self.assertEqual(iface1.name, 'Interface 1')
self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED)
self.assertTrue(iface1.mgmt_only)
- self.assertEqual(dt.rearport_templates.count(), 3)
+ self.assertEqual(dt.rearporttemplates.count(), 3)
rp1 = RearPortTemplate.objects.first()
self.assertEqual(rp1.name, 'Rear Port 1')
- self.assertEqual(dt.frontport_templates.count(), 3)
+ self.assertEqual(dt.frontporttemplates.count(), 3)
fp1 = FrontPortTemplate.objects.first()
self.assertEqual(fp1.name, 'Front Port 1')
self.assertEqual(fp1.rear_port, rp1)
self.assertEqual(fp1.rear_port_position, 1)
- self.assertEqual(dt.device_bay_templates.count(), 3)
+ self.assertEqual(dt.devicebaytemplates.count(), 3)
db1 = DeviceBayTemplate.objects.first()
self.assertEqual(db1.name, 'Device Bay 1')
@@ -699,6 +721,8 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Interface Template [4-6]',
+ # Test that a label can be applied to each generated interface templates
+ 'label_pattern': 'Interface Template Label [3-5]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
@@ -795,9 +819,6 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = DeviceBayTemplate
- # Disable inapplicable views
- test_bulk_edit_objects = None
-
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -823,6 +844,10 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
'name_pattern': 'Device Bay Template [4-6]',
}
+ cls.bulk_edit_data = {
+ 'description': 'Foo bar',
+ }
+
class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = DeviceRole
@@ -930,6 +955,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_role': deviceroles[1].pk,
@@ -950,7 +977,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'vc_position': None,
'vc_priority': None,
'comments': 'A new device',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
'local_context_data': None,
}
@@ -984,20 +1011,24 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsolePort(device=device, name='Console Port 3'),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'name': 'Console Port X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': sorted([t.pk for t in tags]),
}
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Console Port [4-6]',
+ # Test that a label can be applied to each generated console ports
+ 'label_pattern': 'Serial[3-5]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': sorted([t.pk for t in tags]),
}
cls.bulk_edit_data = {
@@ -1026,12 +1057,14 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsoleServerPort(device=device, name='Console Server Port 3'),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'name': 'Console Server Port X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1039,12 +1072,11 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name_pattern': 'Console Server Port [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
- 'device': device.pk,
- 'type': ConsolePortTypeChoices.TYPE_RJ45,
+ 'type': ConsolePortTypeChoices.TYPE_RJ11,
'description': 'New description',
}
@@ -1069,6 +1101,8 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerPort(device=device, name='Power Port 3'),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'name': 'Power Port X',
@@ -1076,7 +1110,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'maximum_draw': 100,
'allocated_draw': 50,
'description': 'A power port',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1086,7 +1120,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'maximum_draw': 100,
'allocated_draw': 50,
'description': 'A power port',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -1123,6 +1157,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'name': 'Power Outlet X',
@@ -1130,7 +1166,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1140,12 +1176,11 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
- 'device': device.pk,
- 'type': PowerOutletTypeChoices.TYPE_IEC_C13,
+ 'type': PowerOutletTypeChoices.TYPE_IEC_C15,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'New description',
@@ -1159,10 +1194,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
-class InterfaceTestCase(
- ViewTestCases.GetObjectViewTestCase,
- ViewTestCases.DeviceComponentViewTestCase,
-):
+class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = Interface
@classmethod
@@ -1185,6 +1217,8 @@ class InterfaceTestCase(
)
VLAN.objects.bulk_create(vlans)
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'virtual_machine': None,
@@ -1199,7 +1233,7 @@ class InterfaceTestCase(
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1215,13 +1249,12 @@ class InterfaceTestCase(
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
- 'device': device.pk,
- 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
- 'enabled': False,
+ 'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
+ 'enabled': True,
'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'),
'mtu': 2000,
@@ -1263,6 +1296,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'name': 'Front Port X',
@@ -1270,7 +1305,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'rear_port': rearports[3].pk,
'rear_port_position': 1,
'description': 'New description',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1281,7 +1316,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'{}:1'.format(rp.pk) for rp in rearports[3:6]
],
'description': 'New description',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -1310,13 +1345,15 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
RearPort(device=device, name='Rear Port 3'),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'name': 'Rear Port X',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 3,
'description': 'A rear port',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1325,7 +1362,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'type': PortTypeChoices.TYPE_8P8C,
'positions': 3,
'description': 'A rear port',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -1357,18 +1394,20 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
DeviceBay(device=device, name='Device Bay 3'),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'name': 'Device Bay X',
'description': 'A device bay',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Device Bay [4-6]',
'description': 'A device bay',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -1397,6 +1436,8 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
InventoryItem(device=device, name='Inventory Item 3'),
])
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'device': device.pk,
'manufacturer': manufacturer.pk,
@@ -1407,7 +1448,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
'serial': '123ABC',
'asset_tag': 'ABC123',
'description': 'An inventory item',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1419,12 +1460,10 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
'part_id': '123456',
'serial': '123ABC',
'description': 'An inventory item',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
- 'device': device.pk,
- 'manufacturer': manufacturer.pk,
'part_id': '123456',
'description': 'New description',
}
@@ -1437,12 +1476,20 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
-class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Change base class to PrimaryObjectViewTestCase
+# Blocked by lack of common creation view for cables (termination A must be initialized)
+class CableTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.GetObjectChangelogViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkImportObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = Cable
- # TODO: Creation URL needs termination context
- test_create_object = None
-
@classmethod
def setUpTestData(cls):
@@ -1479,6 +1526,8 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
interface_ct = ContentType.objects.get_for_model(Interface)
cls.form_data = {
# Changing terminations not supported when editing an existing Cable
@@ -1492,6 +1541,7 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'color': 'c0c0c0',
'length': 100,
'length_unit': CableLengthUnitChoices.UNIT_FOOT,
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -1514,13 +1564,6 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualChassis
- # Disable inapplicable tests
- test_import_objects = None
-
- # TODO: Requires special form handling
- test_create_object = None
- test_edit_object = None
-
@classmethod
def setUpTestData(cls):
@@ -1533,33 +1576,56 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
name='Device Role', slug='device-role-1'
)
- # Create 9 member Devices
- device1 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='Device 1', site=site
+ devices = (
+ Device(device_type=device_type, device_role=device_role, name='Device 1', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 2', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 3', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 4', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 5', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 6', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 7', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 8', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 9', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 10', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 11', site=site),
+ Device(device_type=device_type, device_role=device_role, name='Device 12', site=site),
)
- device2 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='Device 2', site=site
- )
- device3 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='Device 3', site=site
- )
- device4 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='Device 4', site=site
- )
- device5 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='Device 5', site=site
- )
- device6 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='Device 6', site=site
+ Device.objects.bulk_create(devices)
+
+ # Create three VirtualChassis with three members each
+ vc1 = VirtualChassis.objects.create(name='VC1', master=devices[0], domain='domain-1')
+ Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=vc1, vc_position=1)
+ Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2)
+ Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=vc1, vc_position=3)
+ vc2 = VirtualChassis.objects.create(name='VC2', master=devices[3], domain='domain-2')
+ Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=vc2, vc_position=1)
+ Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=vc2, vc_position=2)
+ Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=vc2, vc_position=3)
+ vc3 = VirtualChassis.objects.create(name='VC3', master=devices[6], domain='domain-3')
+ Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=vc3, vc_position=1)
+ Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=vc3, vc_position=2)
+ Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=vc3, vc_position=3)
+
+ cls.form_data = {
+ 'name': 'VC4',
+ 'domain': 'domain-4',
+ # Management form data for VC members
+ 'form-TOTAL_FORMS': 0,
+ 'form-INITIAL_FORMS': 3,
+ 'form-MIN_NUM_FORMS': 0,
+ 'form-MAX_NUM_FORMS': 1000,
+ }
+
+ cls.csv_data = (
+ "name,domain,master",
+ "VC4,Domain 4,Device 10",
+ "VC5,Domain 5,Device 11",
+ "VC6,Domain 6,Device 12",
)
- # Create three VirtualChassis with two members each
- vc1 = VirtualChassis.objects.create(master=device1, domain='test-domain-1')
- Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc1, vc_position=2)
- vc2 = VirtualChassis.objects.create(master=device3, domain='test-domain-2')
- Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2)
- vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3')
- Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
+ cls.bulk_edit_data = {
+ 'domain': 'domain-x',
+ }
class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -1587,10 +1653,13 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'),
))
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'site': sites[1].pk,
'rack_group': rackgroups[1].pk,
'name': 'Power Panel X',
+ 'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -1632,6 +1701,8 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
))
+ tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
cls.form_data = {
'name': 'Power Feed X',
'power_panel': powerpanels[1].pk,
@@ -1644,7 +1715,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'amperage': 100,
'max_utilization': 50,
'comments': 'New comments',
- 'tags': 'Alpha,Bravo,Charlie',
+ 'tags': [t.pk for t in tags],
# Connection
'cable': None,
diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py
index 0b1f6250e..63ae5d2a4 100644
--- a/netbox/dcim/urls.py
+++ b/netbox/dcim/urls.py
@@ -1,12 +1,12 @@
from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
-from ipam.views import ServiceCreateView
+from ipam.views import ServiceEditView
from . import views
from .models import (
- Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
- PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site,
- VirtualChassis,
+ Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface,
+ InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup,
+ RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
)
app_name = 'dcim'
@@ -14,15 +14,16 @@ urlpatterns = [
# Regions
path('regions/', views.RegionListView.as_view(), name='region_list'),
- path('regions/add/', views.RegionCreateView.as_view(), name='region_add'),
+ path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'),
+ path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'),
path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Sites
path('sites/', views.SiteListView.as_view(), name='site_list'),
- path('sites/add/', views.SiteCreateView.as_view(), name='site_add'),
+ path('sites/add/', views.SiteEditView.as_view(), name='site_add'),
path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
@@ -34,23 +35,25 @@ urlpatterns = [
# Rack groups
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
- path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
+ path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'),
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
+ path('rack-groups//delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'),
path('rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
- path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
+ path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
+ path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
- path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'),
+ path('rack-reservations/add/', views.RackReservationEditView.as_view(), name='rackreservation_add'),
path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
@@ -62,7 +65,7 @@ urlpatterns = [
# Racks
path('racks/', views.RackListView.as_view(), name='rack_list'),
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
- path('racks/add/', views.RackCreateView.as_view(), name='rack_add'),
+ path('racks/add/', views.RackEditView.as_view(), name='rack_add'),
path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
@@ -74,15 +77,16 @@ urlpatterns = [
# Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
- path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
+ path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
+ path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
- path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
+ path('device-types/add/', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
@@ -94,6 +98,7 @@ urlpatterns = [
# Console port templates
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
+ path('console-port-templates/rename/', views.ConsolePortTemplateBulkRenameView.as_view(), name='consoleporttemplate_bulk_rename'),
path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
path('console-port-templates//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
path('console-port-templates//delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
@@ -101,6 +106,7 @@ urlpatterns = [
# Console server port templates
path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'),
path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'),
+ path('console-server-port-templates/rename/', views.ConsoleServerPortTemplateBulkRenameView.as_view(), name='consoleserverporttemplate_bulk_rename'),
path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
path('console-server-port-templates//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
path('console-server-port-templates//delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
@@ -108,6 +114,7 @@ urlpatterns = [
# Power port templates
path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'),
path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'),
+ path('power-port-templates/rename/', views.PowerPortTemplateBulkRenameView.as_view(), name='powerporttemplate_bulk_rename'),
path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
path('power-port-templates//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
path('power-port-templates//delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
@@ -115,6 +122,7 @@ urlpatterns = [
# Power outlet templates
path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'),
path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'),
+ path('power-outlet-templates/rename/', views.PowerOutletTemplateBulkRenameView.as_view(), name='poweroutlettemplate_bulk_rename'),
path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
path('power-outlet-templates//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
path('power-outlet-templates//delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
@@ -122,6 +130,7 @@ urlpatterns = [
# Interface templates
path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'),
path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
+ path('interface-templates/rename/', views.InterfaceTemplateBulkRenameView.as_view(), name='interfacetemplate_bulk_rename'),
path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
path('interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
path('interface-templates//delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
@@ -129,6 +138,7 @@ urlpatterns = [
# Front port templates
path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'),
path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'),
+ path('front-port-templates/rename/', views.FrontPortTemplateBulkRenameView.as_view(), name='frontporttemplate_bulk_rename'),
path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
path('front-port-templates//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
path('front-port-templates//delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
@@ -136,36 +146,40 @@ urlpatterns = [
# Rear port templates
path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'),
path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'),
+ path('rear-port-templates/rename/', views.RearPortTemplateBulkRenameView.as_view(), name='rearporttemplate_bulk_rename'),
path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
path('rear-port-templates//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
path('rear-port-templates//delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
# Device bay templates
path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
- # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
+ path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
+ path('device-bay-templates/rename/', views.DeviceBayTemplateBulkRenameView.as_view(), name='devicebaytemplate_bulk_rename'),
path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
# Device roles
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
- path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
+ path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
+ path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms
path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
- path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
+ path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'),
+ path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices
path('devices/', views.DeviceListView.as_view(), name='device_list'),
- path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
+ path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
@@ -179,7 +193,7 @@ urlpatterns = [
path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'),
- path('devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
+ path('devices//services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
path('devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
@@ -187,12 +201,15 @@ urlpatterns = [
path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'),
- # TODO: Bulk rename, disconnect views for ConsolePorts
+ path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'),
+ path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'),
path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
- path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
+ path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'),
path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
+ path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
path('console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
+ path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports
@@ -203,10 +220,12 @@ urlpatterns = [
path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
- path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
+ path('console-server-ports//', views.ConsoleServerPortView.as_view(), name='consoleserverport'),
path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
+ path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
path('console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
+ path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports
@@ -214,12 +233,15 @@ urlpatterns = [
path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'),
- # TODO: Bulk rename, disconnect views for PowerPorts
+ path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'),
+ path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'),
path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
- path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
+ path('power-ports//', views.PowerPortView.as_view(), name='powerport'),
path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
+ path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
path('power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
+ path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets
@@ -230,10 +252,12 @@ urlpatterns = [
path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
- path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
+ path('power-outlets//', views.PowerOutletView.as_view(), name='poweroutlet'),
path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
+ path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
path('power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
+ path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces
@@ -244,12 +268,12 @@ urlpatterns = [
path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
- path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('interfaces//', views.InterfaceView.as_view(), name='interface'),
path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
+ path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports
@@ -260,10 +284,12 @@ urlpatterns = [
path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
- path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
+ path('front-ports//', views.FrontPortView.as_view(), name='frontport'),
path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
+ path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
path('front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
+ path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports
@@ -274,10 +300,12 @@ urlpatterns = [
path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
- path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
+ path('rear-ports//', views.RearPortView.as_view(), name='rearport'),
path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
+ path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
path('rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
+ path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Device bays
@@ -287,8 +315,10 @@ urlpatterns = [
path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'),
path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
+ path('device-bays//', views.DeviceBayView.as_view(), name='devicebay'),
path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
+ path('device-bays//changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}),
path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
@@ -298,10 +328,13 @@ urlpatterns = [
path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'),
path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
- # TODO: Bulk rename view for InventoryItems
+ path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'),
path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
+ path('inventory-items//', views.InventoryItemView.as_view(), name='inventoryitem'),
path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
+ path('inventory-items//changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
+ path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
# Cables
path('cables/', views.CableListView.as_view(), name='cable_list'),
@@ -321,6 +354,7 @@ urlpatterns = [
# Virtual chassis
path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
+ path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'),
path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'),
path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'),
path('virtual-chassis//', views.VirtualChassisView.as_view(), name='virtualchassis'),
@@ -332,7 +366,7 @@ urlpatterns = [
# Power panels
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
- path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
+ path('power-panels/add/', views.PowerPanelEditView.as_view(), name='powerpanel_add'),
path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
@@ -343,7 +377,7 @@ urlpatterns = [
# Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
- path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
+ path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index e193813d2..c016f6e54 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1,32 +1,32 @@
from collections import OrderedDict
-import re
from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
-from django.db.models import Count, F
-from django.forms import modelformset_factory
+from django.db.models import Count, F, Prefetch
+from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
-from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
from django.views.generic import View
from circuits.models import Circuit
from extras.models import Graph
from extras.views import ObjectConfigContextView
-from ipam.models import Prefix, VLAN
+from ipam.models import IPAddress, Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from secrets.models import Secret
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
+from utilities.permissions import get_permission_for_model
from utilities.utils import csv_format, get_subquery
from utilities.views import (
- BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
- ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
+ GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ ObjectPermissionRequiredMixin,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@@ -41,65 +41,28 @@ from .models import (
)
-class BulkRenameView(GetReturnURLMixin, View):
- """
- An extendable view for renaming device components in bulk.
- """
- queryset = None
- form = None
- template_name = 'dcim/bulk_rename.html'
-
- def post(self, request):
-
- model = self.queryset.model
-
- if '_preview' in request.POST or '_apply' in request.POST:
- form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
- selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
-
- if form.is_valid():
- for obj in selected_objects:
- find = form.cleaned_data['find']
- replace = form.cleaned_data['replace']
- if form.cleaned_data['use_regex']:
- try:
- obj.new_name = re.sub(find, replace, obj.name)
- # Catch regex group reference errors
- except re.error:
- obj.new_name = obj.name
- else:
- obj.new_name = obj.name.replace(find, replace)
-
- if '_apply' in request.POST:
- for obj in selected_objects:
- obj.name = obj.new_name
- obj.save()
- messages.success(request, "Renamed {} {}".format(
- len(selected_objects),
- model._meta.verbose_name_plural
- ))
- return redirect(self.get_return_url(request))
-
- else:
- form = self.form(initial={'pk': request.POST.getlist('pk')})
- selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
-
- return render(request, self.template_name, {
- 'form': form,
- 'obj_type_plural': model._meta.verbose_name_plural,
- 'selected_objects': selected_objects,
- 'return_url': self.get_return_url(request),
- })
-
-
-class BulkDisconnectView(GetReturnURLMixin, View):
+class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
An extendable view for disconnection console/power/interface components in bulk.
"""
- model = None
- form = None
+ queryset = None
template_name = 'dcim/bulk_disconnect.html'
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Create a new Form class from ConfirmationForm
+ class _Form(ConfirmationForm):
+ pk = ModelMultipleChoiceField(
+ queryset=self.queryset,
+ widget=MultipleHiddenInput()
+ )
+
+ self.form = _Form
+
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'change')
+
def post(self, request):
selected_objects = []
@@ -113,25 +76,25 @@ class BulkDisconnectView(GetReturnURLMixin, View):
with transaction.atomic():
count = 0
- for obj in self.model.objects.filter(pk__in=form.cleaned_data['pk']):
+ for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
if obj.cable is None:
continue
obj.cable.delete()
count += 1
messages.success(request, "Disconnected {} {}".format(
- count, self.model._meta.verbose_name_plural
+ count, self.queryset.model._meta.verbose_name_plural
))
return redirect(return_url)
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
- selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
+ selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, {
'form': form,
- 'obj_type_plural': self.model._meta.verbose_name_plural,
+ 'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': return_url,
})
@@ -141,8 +104,7 @@ class BulkDisconnectView(GetReturnURLMixin, View):
# Regions
#
-class RegionListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_region'
+class RegionListView(ObjectListView):
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
@@ -155,59 +117,61 @@ class RegionListView(PermissionRequiredMixin, ObjectListView):
table = tables.RegionTable
-class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_region'
- model = Region
+class RegionEditView(ObjectEditView):
+ queryset = Region.objects.all()
model_form = forms.RegionForm
- default_return_url = 'dcim:region_list'
-class RegionEditView(RegionCreateView):
- permission_required = 'dcim.change_region'
+class RegionDeleteView(ObjectDeleteView):
+ queryset = Region.objects.all()
-class RegionBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_region'
+class RegionBulkImportView(BulkImportView):
+ queryset = Region.objects.all()
model_form = forms.RegionCSVForm
table = tables.RegionTable
- default_return_url = 'dcim:region_list'
-class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_region'
- queryset = Region.objects.all()
+class RegionBulkDeleteView(BulkDeleteView):
+ queryset = Region.objects.add_related_count(
+ Region.objects.all(),
+ Site,
+ 'region',
+ 'site_count',
+ cumulative=True
+ )
filterset = filters.RegionFilterSet
table = tables.RegionTable
- default_return_url = 'dcim:region_list'
#
# Sites
#
-class SiteListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_site'
+class SiteListView(ObjectListView):
queryset = Site.objects.prefetch_related('region', 'tenant')
filterset = filters.SiteFilterSet
filterset_form = forms.SiteFilterForm
table = tables.SiteTable
-class SiteView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_site'
+class SiteView(ObjectView):
+ queryset = Site.objects.prefetch_related('region', 'tenant__group')
def get(self, request, slug):
- site = get_object_or_404(Site.objects.prefetch_related('region', 'tenant__group'), slug=slug)
+ site = get_object_or_404(self.queryset, slug=slug)
stats = {
- 'rack_count': Rack.objects.filter(site=site).count(),
- 'device_count': Device.objects.filter(site=site).count(),
- 'prefix_count': Prefix.objects.filter(site=site).count(),
- 'vlan_count': VLAN.objects.filter(site=site).count(),
- 'circuit_count': Circuit.objects.filter(terminations__site=site).count(),
- 'vm_count': VirtualMachine.objects.filter(cluster__site=site).count(),
+ 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=site).count(),
+ 'device_count': Device.objects.restrict(request.user, 'view').filter(site=site).count(),
+ 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=site).count(),
+ 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=site).count(),
+ 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(),
+ 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(),
}
- rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
+ rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate(
+ rack_count=Count('racks')
+ )
show_graphs = Graph.objects.filter(type__model='site').exists()
return render(request, 'dcim/site.html', {
@@ -218,54 +182,40 @@ class SiteView(PermissionRequiredMixin, View):
})
-class SiteCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_site'
- model = Site
+class SiteEditView(ObjectEditView):
+ queryset = Site.objects.all()
model_form = forms.SiteForm
template_name = 'dcim/site_edit.html'
- default_return_url = 'dcim:site_list'
-class SiteEditView(SiteCreateView):
- permission_required = 'dcim.change_site'
+class SiteDeleteView(ObjectDeleteView):
+ queryset = Site.objects.all()
-class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_site'
- model = Site
- default_return_url = 'dcim:site_list'
-
-
-class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_site'
+class SiteBulkImportView(BulkImportView):
+ queryset = Site.objects.all()
model_form = forms.SiteCSVForm
table = tables.SiteTable
- default_return_url = 'dcim:site_list'
-class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_site'
+class SiteBulkEditView(BulkEditView):
queryset = Site.objects.prefetch_related('region', 'tenant')
filterset = filters.SiteFilterSet
table = tables.SiteTable
form = forms.SiteBulkEditForm
- default_return_url = 'dcim:site_list'
-class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_site'
+class SiteBulkDeleteView(BulkDeleteView):
queryset = Site.objects.prefetch_related('region', 'tenant')
filterset = filters.SiteFilterSet
table = tables.SiteTable
- default_return_url = 'dcim:site_list'
#
# Rack groups
#
-class RackGroupListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rackgroup'
+class RackGroupListView(ObjectListView):
queryset = RackGroup.objects.add_related_count(
RackGroup.objects.all(),
Rack,
@@ -278,93 +228,86 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView):
table = tables.RackGroupTable
-class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_rackgroup'
- model = RackGroup
+class RackGroupEditView(ObjectEditView):
+ queryset = RackGroup.objects.all()
model_form = forms.RackGroupForm
- default_return_url = 'dcim:rackgroup_list'
-class RackGroupEditView(RackGroupCreateView):
- permission_required = 'dcim.change_rackgroup'
+class RackGroupDeleteView(ObjectDeleteView):
+ queryset = RackGroup.objects.all()
-class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rackgroup'
+class RackGroupBulkImportView(BulkImportView):
+ queryset = RackGroup.objects.all()
model_form = forms.RackGroupCSVForm
table = tables.RackGroupTable
- default_return_url = 'dcim:rackgroup_list'
-class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rackgroup'
- queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
+class RackGroupBulkDeleteView(BulkDeleteView):
+ queryset = RackGroup.objects.add_related_count(
+ RackGroup.objects.all(),
+ Rack,
+ 'group',
+ 'rack_count',
+ cumulative=True
+ ).prefetch_related('site')
filterset = filters.RackGroupFilterSet
table = tables.RackGroupTable
- default_return_url = 'dcim:rackgroup_list'
#
# Rack roles
#
-class RackRoleListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rackrole'
- queryset = RackRole.objects.annotate(rack_count=Count('racks'))
+class RackRoleListView(ObjectListView):
+ queryset = RackRole.objects.annotate(rack_count=Count('racks')).order_by(*RackRole._meta.ordering)
table = tables.RackRoleTable
-class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_rackrole'
- model = RackRole
+class RackRoleEditView(ObjectEditView):
+ queryset = RackRole.objects.all()
model_form = forms.RackRoleForm
- default_return_url = 'dcim:rackrole_list'
-class RackRoleEditView(RackRoleCreateView):
- permission_required = 'dcim.change_rackrole'
+class RackRoleDeleteView(ObjectDeleteView):
+ queryset = RackRole.objects.all()
-class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rackrole'
+class RackRoleBulkImportView(BulkImportView):
+ queryset = RackRole.objects.all()
model_form = forms.RackRoleCSVForm
table = tables.RackRoleTable
- default_return_url = 'dcim:rackrole_list'
-class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rackrole'
- queryset = RackRole.objects.annotate(rack_count=Count('racks'))
+class RackRoleBulkDeleteView(BulkDeleteView):
+ queryset = RackRole.objects.annotate(rack_count=Count('racks')).order_by(*RackRole._meta.ordering)
table = tables.RackRoleTable
- default_return_url = 'dcim:rackrole_list'
#
# Racks
#
-class RackListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rack'
+class RackListView(ObjectListView):
queryset = Rack.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'devices__device_type'
).annotate(
device_count=Count('devices')
- )
+ ).order_by(*Rack._meta.ordering)
filterset = filters.RackFilterSet
filterset_form = forms.RackFilterForm
table = tables.RackDetailTable
-class RackElevationListView(PermissionRequiredMixin, View):
+class RackElevationListView(ObjectListView):
"""
Display a set of rack elevations side-by-side.
"""
- permission_required = 'dcim.view_rack'
+ queryset = Rack.objects.prefetch_related('role')
def get(self, request):
- racks = Rack.objects.prefetch_related('role')
- racks = filters.RackFilterSet(request.GET, racks).qs
+ racks = filters.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count()
# Pagination
@@ -392,12 +335,11 @@ class RackElevationListView(PermissionRequiredMixin, View):
})
-class RackView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_rack'
+class RackView(ObjectView):
+ queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
def get(self, request, pk):
-
- rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
+ rack = get_object_or_404(self.queryset, pk=pk)
# Get 0U and child devices located within the rack
nonracked_devices = Device.objects.filter(
@@ -405,18 +347,21 @@ class RackView(PermissionRequiredMixin, View):
position__isnull=True
).prefetch_related('device_type__manufacturer')
+ peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=rack.site)
+
if rack.group:
- peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
+ peer_racks = peer_racks.filter(group=rack.group)
else:
- peer_racks = Rack.objects.filter(site=rack.site, group__isnull=True)
+ peer_racks = peer_racks.filter(group__isnull=True)
next_rack = peer_racks.filter(name__gt=rack.name).order_by('name').first()
prev_rack = peer_racks.filter(name__lt=rack.name).order_by('-name').first()
- reservations = RackReservation.objects.filter(rack=rack)
- power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel')
+ reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=rack)
+ power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=rack).prefetch_related('power_panel')
return render(request, 'dcim/rack.html', {
'rack': rack,
+ 'device_count': Device.objects.restrict(request.user, 'view').filter(rack=rack).count(),
'reservations': reservations,
'power_feeds': power_feeds,
'nonracked_devices': nonracked_devices,
@@ -425,54 +370,40 @@ class RackView(PermissionRequiredMixin, View):
})
-class RackCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_rack'
- model = Rack
+class RackEditView(ObjectEditView):
+ queryset = Rack.objects.all()
model_form = forms.RackForm
template_name = 'dcim/rack_edit.html'
- default_return_url = 'dcim:rack_list'
-class RackEditView(RackCreateView):
- permission_required = 'dcim.change_rack'
+class RackDeleteView(ObjectDeleteView):
+ queryset = Rack.objects.all()
-class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_rack'
- model = Rack
- default_return_url = 'dcim:rack_list'
-
-
-class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rack'
+class RackBulkImportView(BulkImportView):
+ queryset = Rack.objects.all()
model_form = forms.RackCSVForm
table = tables.RackTable
- default_return_url = 'dcim:rack_list'
-class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_rack'
+class RackBulkEditView(BulkEditView):
queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.RackFilterSet
table = tables.RackTable
form = forms.RackBulkEditForm
- default_return_url = 'dcim:rack_list'
-class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rack'
+class RackBulkDeleteView(BulkDeleteView):
queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.RackFilterSet
table = tables.RackTable
- default_return_url = 'dcim:rack_list'
#
# Rack reservations
#
-class RackReservationListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rackreservation'
+class RackReservationListView(ObjectListView):
queryset = RackReservation.objects.prefetch_related('rack__site')
filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
@@ -480,24 +411,22 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView):
action_buttons = ('export',)
-class RackReservationView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_rackreservation'
+class RackReservationView(ObjectView):
+ queryset = RackReservation.objects.prefetch_related('rack')
def get(self, request, pk):
- rackreservation = get_object_or_404(RackReservation.objects.prefetch_related('rack'), pk=pk)
+ rackreservation = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/rackreservation.html', {
'rackreservation': rackreservation,
})
-class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_rackreservation'
- model = RackReservation
+class RackReservationEditView(ObjectEditView):
+ queryset = RackReservation.objects.all()
model_form = forms.RackReservationForm
template_name = 'dcim/rackreservation_edit.html'
- default_return_url = 'dcim:rackreservation_list'
def alter_obj(self, obj, request, args, kwargs):
if not obj.pk:
@@ -507,21 +436,14 @@ class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
return obj
-class RackReservationEditView(RackReservationCreateView):
- permission_required = 'dcim.change_rackreservation'
+class RackReservationDeleteView(ObjectDeleteView):
+ queryset = RackReservation.objects.all()
-class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_rackreservation'
- model = RackReservation
- default_return_url = 'dcim:rackreservation_list'
-
-
-class RackReservationImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rackreservation'
+class RackReservationImportView(BulkImportView):
+ queryset = RackReservation.objects.all()
model_form = forms.RackReservationCSVForm
table = tables.RackReservationTable
- default_return_url = 'dcim:rackreservation_list'
def _save_obj(self, obj_form, request):
"""
@@ -534,29 +456,24 @@ class RackReservationImportView(PermissionRequiredMixin, BulkImportView):
return instance
-class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_rackreservation'
+class RackReservationBulkEditView(BulkEditView):
queryset = RackReservation.objects.prefetch_related('rack', 'user')
filterset = filters.RackReservationFilterSet
table = tables.RackReservationTable
form = forms.RackReservationBulkEditForm
- default_return_url = 'dcim:rackreservation_list'
-class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rackreservation'
+class RackReservationBulkDeleteView(BulkDeleteView):
queryset = RackReservation.objects.prefetch_related('rack', 'user')
filterset = filters.RackReservationFilterSet
table = tables.RackReservationTable
- default_return_url = 'dcim:rackreservation_list'
#
# Manufacturers
#
-class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_manufacturer'
+class ManufacturerListView(ObjectListView):
queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
@@ -565,81 +482,80 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
table = tables.ManufacturerTable
-class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_manufacturer'
- model = Manufacturer
+class ManufacturerEditView(ObjectEditView):
+ queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerForm
- default_return_url = 'dcim:manufacturer_list'
-class ManufacturerEditView(ManufacturerCreateView):
- permission_required = 'dcim.change_manufacturer'
+class ManufacturerDeleteView(ObjectDeleteView):
+ queryset = Manufacturer.objects.all()
-class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_manufacturer'
+class ManufacturerBulkImportView(BulkImportView):
+ queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerCSVForm
table = tables.ManufacturerTable
- default_return_url = 'dcim:manufacturer_list'
-class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_manufacturer'
- queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
+class ManufacturerBulkDeleteView(BulkDeleteView):
+ queryset = Manufacturer.objects.annotate(
+ devicetype_count=Count('device_types')
+ ).order_by(*Manufacturer._meta.ordering)
table = tables.ManufacturerTable
- default_return_url = 'dcim:manufacturer_list'
#
# Device types
#
-class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_devicetype'
- queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
+class DeviceTypeListView(ObjectListView):
+ queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+ instance_count=Count('instances')
+ ).order_by(*DeviceType._meta.ordering)
filterset = filters.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable
-class DeviceTypeView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_devicetype'
+class DeviceTypeView(ObjectView):
+ queryset = DeviceType.objects.prefetch_related('manufacturer')
def get(self, request, pk):
- devicetype = get_object_or_404(DeviceType, pk=pk)
+ devicetype = get_object_or_404(self.queryset, pk=pk)
+ instance_count = Device.objects.restrict(request.user).filter(device_type=devicetype).count()
# Component tables
consoleport_table = tables.ConsolePortTemplateTable(
- ConsolePortTemplate.objects.filter(device_type=devicetype),
+ ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
consoleserverport_table = tables.ConsoleServerPortTemplateTable(
- ConsoleServerPortTemplate.objects.filter(device_type=devicetype),
+ ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
powerport_table = tables.PowerPortTemplateTable(
- PowerPortTemplate.objects.filter(device_type=devicetype),
+ PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
poweroutlet_table = tables.PowerOutletTemplateTable(
- PowerOutletTemplate.objects.filter(device_type=devicetype),
+ PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
interface_table = tables.InterfaceTemplateTable(
- list(InterfaceTemplate.objects.filter(device_type=devicetype)),
+ list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype)),
orderable=False
)
front_port_table = tables.FrontPortTemplateTable(
- FrontPortTemplate.objects.filter(device_type=devicetype),
+ FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
rear_port_table = tables.RearPortTemplateTable(
- RearPortTemplate.objects.filter(device_type=devicetype),
+ RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
devicebay_table = tables.DeviceBayTemplateTable(
- DeviceBayTemplate.objects.filter(device_type=devicetype),
+ DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
if request.user.has_perm('dcim.change_devicetype'):
@@ -654,6 +570,7 @@ class DeviceTypeView(PermissionRequiredMixin, View):
return render(request, 'dcim/devicetype.html', {
'devicetype': devicetype,
+ 'instance_count': instance_count,
'consoleport_table': consoleport_table,
'consoleserverport_table': consoleserverport_table,
'powerport_table': powerport_table,
@@ -665,26 +582,18 @@ class DeviceTypeView(PermissionRequiredMixin, View):
})
-class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_devicetype'
- model = DeviceType
+class DeviceTypeEditView(ObjectEditView):
+ queryset = DeviceType.objects.all()
model_form = forms.DeviceTypeForm
template_name = 'dcim/devicetype_edit.html'
- default_return_url = 'dcim:devicetype_list'
-class DeviceTypeEditView(DeviceTypeCreateView):
- permission_required = 'dcim.change_devicetype'
+class DeviceTypeDeleteView(ObjectDeleteView):
+ queryset = DeviceType.objects.all()
-class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_devicetype'
- model = DeviceType
- default_return_url = 'dcim:devicetype_list'
-
-
-class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView):
- permission_required = [
+class DeviceTypeImportView(ObjectImportView):
+ additional_permissions = [
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
@@ -695,7 +604,7 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView):
'dcim.add_rearporttemplate',
'dcim.add_devicebaytemplate',
]
- model = DeviceType
+ queryset = DeviceType.objects.all()
model_form = forms.DeviceTypeImportForm
related_object_forms = OrderedDict((
('console-ports', forms.ConsolePortTemplateImportForm),
@@ -707,58 +616,56 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView):
('front-ports', forms.FrontPortTemplateImportForm),
('device-bays', forms.DeviceBayTemplateImportForm),
))
- default_return_url = 'dcim:devicetype_import'
-class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_devicetype'
- queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
+class DeviceTypeBulkEditView(BulkEditView):
+ queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+ instance_count=Count('instances')
+ ).order_by(*DeviceType._meta.ordering)
filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable
form = forms.DeviceTypeBulkEditForm
- default_return_url = 'dcim:devicetype_list'
-class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_devicetype'
- queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
+class DeviceTypeBulkDeleteView(BulkDeleteView):
+ queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+ instance_count=Count('instances')
+ ).order_by(*DeviceType._meta.ordering)
filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable
- default_return_url = 'dcim:devicetype_list'
#
# Console port templates
#
-class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_consoleporttemplate'
- model = ConsolePortTemplate
+class ConsolePortTemplateCreateView(ComponentCreateView):
+ queryset = ConsolePortTemplate.objects.all()
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
template_name = 'dcim/device_component_add.html'
-class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_consoleporttemplate'
- model = ConsolePortTemplate
+class ConsolePortTemplateEditView(ObjectEditView):
+ queryset = ConsolePortTemplate.objects.all()
model_form = forms.ConsolePortTemplateForm
-class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_consoleporttemplate'
- model = ConsolePortTemplate
+class ConsolePortTemplateDeleteView(ObjectDeleteView):
+ queryset = ConsolePortTemplate.objects.all()
-class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_consoleporttemplate'
+class ConsolePortTemplateBulkEditView(BulkEditView):
queryset = ConsolePortTemplate.objects.all()
table = tables.ConsolePortTemplateTable
form = forms.ConsolePortTemplateBulkEditForm
-class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_consoleporttemplate'
+class ConsolePortTemplateBulkRenameView(BulkRenameView):
+ queryset = ConsolePortTemplate.objects.all()
+
+
+class ConsolePortTemplateBulkDeleteView(BulkDeleteView):
queryset = ConsolePortTemplate.objects.all()
table = tables.ConsolePortTemplateTable
@@ -767,34 +674,33 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
# Console server port templates
#
-class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_consoleserverporttemplate'
- model = ConsoleServerPortTemplate
+class ConsoleServerPortTemplateCreateView(ComponentCreateView):
+ queryset = ConsoleServerPortTemplate.objects.all()
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
template_name = 'dcim/device_component_add.html'
-class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_consoleserverporttemplate'
- model = ConsoleServerPortTemplate
+class ConsoleServerPortTemplateEditView(ObjectEditView):
+ queryset = ConsoleServerPortTemplate.objects.all()
model_form = forms.ConsoleServerPortTemplateForm
-class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_consoleserverporttemplate'
- model = ConsoleServerPortTemplate
+class ConsoleServerPortTemplateDeleteView(ObjectDeleteView):
+ queryset = ConsoleServerPortTemplate.objects.all()
-class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_consoleserverporttemplate'
+class ConsoleServerPortTemplateBulkEditView(BulkEditView):
queryset = ConsoleServerPortTemplate.objects.all()
table = tables.ConsoleServerPortTemplateTable
form = forms.ConsoleServerPortTemplateBulkEditForm
-class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_consoleserverporttemplate'
+class ConsoleServerPortTemplateBulkRenameView(BulkRenameView):
+ queryset = ConsoleServerPortTemplate.objects.all()
+
+
+class ConsoleServerPortTemplateBulkDeleteView(BulkDeleteView):
queryset = ConsoleServerPortTemplate.objects.all()
table = tables.ConsoleServerPortTemplateTable
@@ -803,34 +709,33 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
# Power port templates
#
-class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_powerporttemplate'
- model = PowerPortTemplate
+class PowerPortTemplateCreateView(ComponentCreateView):
+ queryset = PowerPortTemplate.objects.all()
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
template_name = 'dcim/device_component_add.html'
-class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_powerporttemplate'
- model = PowerPortTemplate
+class PowerPortTemplateEditView(ObjectEditView):
+ queryset = PowerPortTemplate.objects.all()
model_form = forms.PowerPortTemplateForm
-class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_powerporttemplate'
- model = PowerPortTemplate
+class PowerPortTemplateDeleteView(ObjectDeleteView):
+ queryset = PowerPortTemplate.objects.all()
-class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_powerporttemplate'
+class PowerPortTemplateBulkEditView(BulkEditView):
queryset = PowerPortTemplate.objects.all()
table = tables.PowerPortTemplateTable
form = forms.PowerPortTemplateBulkEditForm
-class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_powerporttemplate'
+class PowerPortTemplateBulkRenameView(BulkRenameView):
+ queryset = PowerPortTemplate.objects.all()
+
+
+class PowerPortTemplateBulkDeleteView(BulkDeleteView):
queryset = PowerPortTemplate.objects.all()
table = tables.PowerPortTemplateTable
@@ -839,34 +744,33 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Power outlet templates
#
-class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_poweroutlettemplate'
- model = PowerOutletTemplate
+class PowerOutletTemplateCreateView(ComponentCreateView):
+ queryset = PowerOutletTemplate.objects.all()
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
template_name = 'dcim/device_component_add.html'
-class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_poweroutlettemplate'
- model = PowerOutletTemplate
+class PowerOutletTemplateEditView(ObjectEditView):
+ queryset = PowerOutletTemplate.objects.all()
model_form = forms.PowerOutletTemplateForm
-class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_poweroutlettemplate'
- model = PowerOutletTemplate
+class PowerOutletTemplateDeleteView(ObjectDeleteView):
+ queryset = PowerOutletTemplate.objects.all()
-class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_poweroutlettemplate'
+class PowerOutletTemplateBulkEditView(BulkEditView):
queryset = PowerOutletTemplate.objects.all()
table = tables.PowerOutletTemplateTable
form = forms.PowerOutletTemplateBulkEditForm
-class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_poweroutlettemplate'
+class PowerOutletTemplateBulkRenameView(BulkRenameView):
+ queryset = PowerOutletTemplate.objects.all()
+
+
+class PowerOutletTemplateBulkDeleteView(BulkDeleteView):
queryset = PowerOutletTemplate.objects.all()
table = tables.PowerOutletTemplateTable
@@ -875,34 +779,33 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
# Interface templates
#
-class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_interfacetemplate'
- model = InterfaceTemplate
+class InterfaceTemplateCreateView(ComponentCreateView):
+ queryset = InterfaceTemplate.objects.all()
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
template_name = 'dcim/device_component_add.html'
-class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_interfacetemplate'
- model = InterfaceTemplate
+class InterfaceTemplateEditView(ObjectEditView):
+ queryset = InterfaceTemplate.objects.all()
model_form = forms.InterfaceTemplateForm
-class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_interfacetemplate'
- model = InterfaceTemplate
+class InterfaceTemplateDeleteView(ObjectDeleteView):
+ queryset = InterfaceTemplate.objects.all()
-class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_interfacetemplate'
+class InterfaceTemplateBulkEditView(BulkEditView):
queryset = InterfaceTemplate.objects.all()
table = tables.InterfaceTemplateTable
form = forms.InterfaceTemplateBulkEditForm
-class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_interfacetemplate'
+class InterfaceTemplateBulkRenameView(BulkRenameView):
+ queryset = InterfaceTemplate.objects.all()
+
+
+class InterfaceTemplateBulkDeleteView(BulkDeleteView):
queryset = InterfaceTemplate.objects.all()
table = tables.InterfaceTemplateTable
@@ -911,34 +814,33 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Front port templates
#
-class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_frontporttemplate'
- model = FrontPortTemplate
+class FrontPortTemplateCreateView(ComponentCreateView):
+ queryset = FrontPortTemplate.objects.all()
form = forms.FrontPortTemplateCreateForm
model_form = forms.FrontPortTemplateForm
template_name = 'dcim/device_component_add.html'
-class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_frontporttemplate'
- model = FrontPortTemplate
+class FrontPortTemplateEditView(ObjectEditView):
+ queryset = FrontPortTemplate.objects.all()
model_form = forms.FrontPortTemplateForm
-class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_frontporttemplate'
- model = FrontPortTemplate
+class FrontPortTemplateDeleteView(ObjectDeleteView):
+ queryset = FrontPortTemplate.objects.all()
-class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_frontporttemplate'
+class FrontPortTemplateBulkEditView(BulkEditView):
queryset = FrontPortTemplate.objects.all()
table = tables.FrontPortTemplateTable
form = forms.FrontPortTemplateBulkEditForm
-class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_frontporttemplate'
+class FrontPortTemplateBulkRenameView(BulkRenameView):
+ queryset = FrontPortTemplate.objects.all()
+
+
+class FrontPortTemplateBulkDeleteView(BulkDeleteView):
queryset = FrontPortTemplate.objects.all()
table = tables.FrontPortTemplateTable
@@ -947,34 +849,33 @@ class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rear port templates
#
-class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_rearporttemplate'
- model = RearPortTemplate
+class RearPortTemplateCreateView(ComponentCreateView):
+ queryset = RearPortTemplate.objects.all()
form = forms.RearPortTemplateCreateForm
model_form = forms.RearPortTemplateForm
template_name = 'dcim/device_component_add.html'
-class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_rearporttemplate'
- model = RearPortTemplate
+class RearPortTemplateEditView(ObjectEditView):
+ queryset = RearPortTemplate.objects.all()
model_form = forms.RearPortTemplateForm
-class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_rearporttemplate'
- model = RearPortTemplate
+class RearPortTemplateDeleteView(ObjectDeleteView):
+ queryset = RearPortTemplate.objects.all()
-class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_rearporttemplate'
+class RearPortTemplateBulkEditView(BulkEditView):
queryset = RearPortTemplate.objects.all()
table = tables.RearPortTemplateTable
form = forms.RearPortTemplateBulkEditForm
-class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rearporttemplate'
+class RearPortTemplateBulkRenameView(BulkRenameView):
+ queryset = RearPortTemplate.objects.all()
+
+
+class RearPortTemplateBulkDeleteView(BulkDeleteView):
queryset = RearPortTemplate.objects.all()
table = tables.RearPortTemplateTable
@@ -983,34 +884,33 @@ class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device bay templates
#
-class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_devicebaytemplate'
- model = DeviceBayTemplate
+class DeviceBayTemplateCreateView(ComponentCreateView):
+ queryset = DeviceBayTemplate.objects.all()
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
template_name = 'dcim/device_component_add.html'
-class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_devicebaytemplate'
- model = DeviceBayTemplate
+class DeviceBayTemplateEditView(ObjectEditView):
+ queryset = DeviceBayTemplate.objects.all()
model_form = forms.DeviceBayTemplateForm
-class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_devicebaytemplate'
- model = DeviceBayTemplate
+class DeviceBayTemplateDeleteView(ObjectDeleteView):
+ queryset = DeviceBayTemplate.objects.all()
-# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
-# permission_required = 'dcim.change_devicebaytemplate'
-# queryset = DeviceBayTemplate.objects.all()
-# table = tables.DeviceBayTemplateTable
-# form = forms.DeviceBayTemplateBulkEditForm
+class DeviceBayTemplateBulkEditView(BulkEditView):
+ queryset = DeviceBayTemplate.objects.all()
+ table = tables.DeviceBayTemplateTable
+ form = forms.DeviceBayTemplateBulkEditForm
-class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_devicebaytemplate'
+class DeviceBayTemplateBulkRenameView(BulkRenameView):
+ queryset = DeviceBayTemplate.objects.all()
+
+
+class DeviceBayTemplateBulkDeleteView(BulkDeleteView):
queryset = DeviceBayTemplate.objects.all()
table = tables.DeviceBayTemplateTable
@@ -1019,8 +919,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device roles
#
-class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_devicerole'
+class DeviceRoleListView(ObjectListView):
queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
vm_count=get_subquery(VirtualMachine, 'role')
@@ -1028,37 +927,31 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
table = tables.DeviceRoleTable
-class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_devicerole'
- model = DeviceRole
+class DeviceRoleEditView(ObjectEditView):
+ queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleForm
- default_return_url = 'dcim:devicerole_list'
-class DeviceRoleEditView(DeviceRoleCreateView):
- permission_required = 'dcim.change_devicerole'
+class DeviceRoleDeleteView(ObjectDeleteView):
+ queryset = DeviceRole.objects.all()
-class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_devicerole'
+class DeviceRoleBulkImportView(BulkImportView):
+ queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleCSVForm
table = tables.DeviceRoleTable
- default_return_url = 'dcim:devicerole_list'
-class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_devicerole'
+class DeviceRoleBulkDeleteView(BulkDeleteView):
queryset = DeviceRole.objects.all()
table = tables.DeviceRoleTable
- default_return_url = 'dcim:devicerole_list'
#
# Platforms
#
-class PlatformListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_platform'
+class PlatformListView(ObjectListView):
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'),
vm_count=get_subquery(VirtualMachine, 'platform')
@@ -1066,37 +959,31 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView):
table = tables.PlatformTable
-class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_platform'
- model = Platform
+class PlatformEditView(ObjectEditView):
+ queryset = Platform.objects.all()
model_form = forms.PlatformForm
- default_return_url = 'dcim:platform_list'
-class PlatformEditView(PlatformCreateView):
- permission_required = 'dcim.change_platform'
+class PlatformDeleteView(ObjectDeleteView):
+ queryset = Platform.objects.all()
-class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_platform'
+class PlatformBulkImportView(BulkImportView):
+ queryset = Platform.objects.all()
model_form = forms.PlatformCSVForm
table = tables.PlatformTable
- default_return_url = 'dcim:platform_list'
-class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_platform'
+class PlatformBulkDeleteView(BulkDeleteView):
queryset = Platform.objects.all()
table = tables.PlatformTable
- default_return_url = 'dcim:platform_list'
#
# Devices
#
-class DeviceListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_device'
+class DeviceListView(ObjectListView):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
)
@@ -1106,58 +993,74 @@ class DeviceListView(PermissionRequiredMixin, ObjectListView):
template_name = 'dcim/device_list.html'
-class DeviceView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_device'
+class DeviceView(ObjectView):
+ queryset = Device.objects.prefetch_related(
+ 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
+ )
def get(self, request, pk):
- device = get_object_or_404(Device.objects.prefetch_related(
- 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
- ), pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
# VirtualChassis members
if device.virtual_chassis is not None:
- vc_members = Device.objects.filter(
+ vc_members = Device.objects.restrict(request.user, 'view').filter(
virtual_chassis=device.virtual_chassis
).order_by('vc_position')
else:
vc_members = []
# Console ports
- console_ports = device.consoleports.prefetch_related('connected_endpoint__device', 'cable')
+ consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ 'connected_endpoint__device', 'cable',
+ )
# Console server ports
- consoleserverports = device.consoleserverports.prefetch_related('connected_endpoint__device', 'cable')
+ consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter(
+ device=device
+ ).prefetch_related(
+ 'connected_endpoint__device', 'cable',
+ )
# Power ports
- power_ports = device.powerports.prefetch_related('_connected_poweroutlet__device', 'cable')
+ powerports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ '_connected_poweroutlet__device', 'cable',
+ )
# Power outlets
- poweroutlets = device.poweroutlets.prefetch_related('connected_endpoint__device', 'cable', 'power_port')
+ poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ 'connected_endpoint__device', 'cable', 'power_port',
+ )
# Interfaces
- interfaces = device.vc_interfaces.prefetch_related(
+ interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
+ Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user)),
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
- 'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags'
+ 'cable__termination_a', 'cable__termination_b', 'tags'
)
# Front ports
- front_ports = device.frontports.prefetch_related('rear_port', 'cable')
+ frontports = FrontPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ 'rear_port', 'cable',
+ )
# Rear ports
- rear_ports = device.rearports.prefetch_related('cable')
+ rearports = RearPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related('cable')
# Device bays
- device_bays = device.device_bays.prefetch_related('installed_device__device_type__manufacturer')
+ devicebays = DeviceBay.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ 'installed_device__device_type__manufacturer',
+ )
# Services
- services = device.services.all()
+ services = Service.objects.restrict(request.user, 'view').filter(device=device)
# Secrets
- secrets = device.secrets.all()
+ secrets = Secret.objects.restrict(request.user, 'view').filter(device=device)
# Find up to ten devices in the same site with the same functional role for quick reference.
- related_devices = Device.objects.filter(
+ related_devices = Device.objects.restrict(request.user, 'view').filter(
site=device.site, device_role=device.device_role
).exclude(
pk=device.pk
@@ -1167,14 +1070,14 @@ class DeviceView(PermissionRequiredMixin, View):
return render(request, 'dcim/device.html', {
'device': device,
- 'console_ports': console_ports,
+ 'consoleports': consoleports,
'consoleserverports': consoleserverports,
- 'power_ports': power_ports,
+ 'powerports': powerports,
'poweroutlets': poweroutlets,
'interfaces': interfaces,
- 'device_bays': device_bays,
- 'front_ports': front_ports,
- 'rear_ports': rear_ports,
+ 'devicebays': devicebays,
+ 'frontports': frontports,
+ 'rearports': rearports,
'services': services,
'secrets': secrets,
'vc_members': vc_members,
@@ -1184,13 +1087,13 @@ class DeviceView(PermissionRequiredMixin, View):
})
-class DeviceInventoryView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_device'
+class DeviceInventoryView(ObjectView):
+ queryset = Device.objects.all()
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk)
- inventory_items = InventoryItem.objects.filter(
+ device = get_object_or_404(self.queryset, pk=pk)
+ inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter(
device=device, parent=None
).prefetch_related(
'manufacturer', 'child_items'
@@ -1203,12 +1106,13 @@ class DeviceInventoryView(PermissionRequiredMixin, View):
})
-class DeviceStatusView(PermissionRequiredMixin, View):
- permission_required = ('dcim.view_device', 'dcim.napalm_read')
+class DeviceStatusView(ObjectView):
+ additional_permissions = ['dcim.napalm_read_device']
+ queryset = Device.objects.all()
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/device_status.html', {
'device': device,
@@ -1216,13 +1120,16 @@ class DeviceStatusView(PermissionRequiredMixin, View):
})
-class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
- permission_required = ('dcim.view_device', 'dcim.napalm_read')
+class DeviceLLDPNeighborsView(ObjectView):
+ additional_permissions = ['dcim.napalm_read_device']
+ queryset = Device.objects.all()
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk)
- interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related(
+ device = get_object_or_404(self.queryset, pk=pk)
+ interfaces = device.vc_interfaces.restrict(request.user, 'view').exclude(
+ type__in=NONCONNECTABLE_IFACE_TYPES
+ ).prefetch_related(
'_connected_interface__device'
)
@@ -1233,12 +1140,13 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
})
-class DeviceConfigView(PermissionRequiredMixin, View):
- permission_required = ('dcim.view_device', 'dcim.napalm_read')
+class DeviceConfigView(ObjectView):
+ additional_permissions = ['dcim.napalm_read_device']
+ queryset = Device.objects.all()
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/device_config.html', {
'device': device,
@@ -1246,44 +1154,33 @@ class DeviceConfigView(PermissionRequiredMixin, View):
})
-class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
- permission_required = 'dcim.view_device'
- object_class = Device
+class DeviceConfigContextView(ObjectConfigContextView):
+ queryset = Device.objects.all()
base_template = 'dcim/device.html'
-class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_device'
- model = Device
+class DeviceEditView(ObjectEditView):
+ queryset = Device.objects.all()
model_form = forms.DeviceForm
template_name = 'dcim/device_edit.html'
- default_return_url = 'dcim:device_list'
-class DeviceEditView(DeviceCreateView):
- permission_required = 'dcim.change_device'
+class DeviceDeleteView(ObjectDeleteView):
+ queryset = Device.objects.all()
-class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_device'
- model = Device
- default_return_url = 'dcim:device_list'
-
-
-class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_device'
+class DeviceBulkImportView(BulkImportView):
+ queryset = Device.objects.all()
model_form = forms.DeviceCSVForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import.html'
- default_return_url = 'dcim:device_list'
-class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_device'
+class ChildDeviceBulkImportView(BulkImportView):
+ queryset = Device.objects.all()
model_form = forms.ChildDeviceCSVForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import_child.html'
- default_return_url = 'dcim:device_list'
def _save_obj(self, obj_form, request):
@@ -1297,290 +1194,277 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
return obj
-class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_device'
+class DeviceBulkEditView(BulkEditView):
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
form = forms.DeviceBulkEditForm
- default_return_url = 'dcim:device_list'
-class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_device'
+class DeviceBulkDeleteView(BulkDeleteView):
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
- default_return_url = 'dcim:device_list'
#
# Console ports
#
-class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_consoleport'
- queryset = ConsolePort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
+class ConsolePortListView(ObjectListView):
+ queryset = ConsolePort.objects.prefetch_related('device', 'cable')
filterset = filters.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
- table = tables.ConsolePortDetailTable
+ table = tables.ConsolePortTable
action_buttons = ('import', 'export')
-class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_consoleport'
- model = ConsolePort
+class ConsolePortView(ObjectView):
+ queryset = ConsolePort.objects.all()
+
+
+class ConsolePortCreateView(ComponentCreateView):
+ queryset = ConsolePort.objects.all()
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
template_name = 'dcim/device_component_add.html'
-class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_consoleport'
- model = ConsolePort
+class ConsolePortEditView(ObjectEditView):
+ queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortForm
-class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_consoleport'
- model = ConsolePort
+class ConsolePortDeleteView(ObjectDeleteView):
+ queryset = ConsolePort.objects.all()
-class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_consoleport'
+class ConsolePortBulkImportView(BulkImportView):
+ queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortCSVForm
- table = tables.ConsolePortImportTable
- default_return_url = 'dcim:consoleport_list'
+ table = tables.ConsolePortTable
-class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_consoleport'
+class ConsolePortBulkEditView(BulkEditView):
queryset = ConsolePort.objects.all()
filterset = filters.ConsolePortFilterSet
table = tables.ConsolePortTable
form = forms.ConsolePortBulkEditForm
-class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_consoleport'
+class ConsolePortBulkRenameView(BulkRenameView):
+ queryset = ConsolePort.objects.all()
+
+
+class ConsolePortBulkDisconnectView(BulkDisconnectView):
+ queryset = ConsolePort.objects.all()
+
+
+class ConsolePortBulkDeleteView(BulkDeleteView):
queryset = ConsolePort.objects.all()
filterset = filters.ConsolePortFilterSet
table = tables.ConsolePortTable
- default_return_url = 'dcim:consoleport_list'
#
# Console server ports
#
-class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_consoleserverport'
- queryset = ConsoleServerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
+class ConsoleServerPortListView(ObjectListView):
+ queryset = ConsoleServerPort.objects.prefetch_related('device', 'cable')
filterset = filters.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
- table = tables.ConsoleServerPortDetailTable
+ table = tables.ConsoleServerPortTable
action_buttons = ('import', 'export')
-class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_consoleserverport'
- model = ConsoleServerPort
+class ConsoleServerPortView(ObjectView):
+ queryset = ConsoleServerPort.objects.all()
+
+
+class ConsoleServerPortCreateView(ComponentCreateView):
+ queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
template_name = 'dcim/device_component_add.html'
-class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_consoleserverport'
- model = ConsoleServerPort
+class ConsoleServerPortEditView(ObjectEditView):
+ queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortForm
-class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_consoleserverport'
- model = ConsoleServerPort
+class ConsoleServerPortDeleteView(ObjectDeleteView):
+ queryset = ConsoleServerPort.objects.all()
-class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_consoleserverport'
+class ConsoleServerPortBulkImportView(BulkImportView):
+ queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortCSVForm
- table = tables.ConsoleServerPortImportTable
- default_return_url = 'dcim:consoleserverport_list'
+ table = tables.ConsoleServerPortTable
-class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_consoleserverport'
+class ConsoleServerPortBulkEditView(BulkEditView):
queryset = ConsoleServerPort.objects.all()
filterset = filters.ConsoleServerPortFilterSet
table = tables.ConsoleServerPortTable
form = forms.ConsoleServerPortBulkEditForm
-class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_consoleserverport'
+class ConsoleServerPortBulkRenameView(BulkRenameView):
queryset = ConsoleServerPort.objects.all()
- form = forms.ConsoleServerPortBulkRenameForm
-class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_consoleserverport'
- model = ConsoleServerPort
- form = forms.ConsoleServerPortBulkDisconnectForm
+class ConsoleServerPortBulkDisconnectView(BulkDisconnectView):
+ queryset = ConsoleServerPort.objects.all()
-class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_consoleserverport'
+class ConsoleServerPortBulkDeleteView(BulkDeleteView):
queryset = ConsoleServerPort.objects.all()
filterset = filters.ConsoleServerPortFilterSet
table = tables.ConsoleServerPortTable
- default_return_url = 'dcim:consoleserverport_list'
#
# Power ports
#
-class PowerPortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_powerport'
- queryset = PowerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
+class PowerPortListView(ObjectListView):
+ queryset = PowerPort.objects.prefetch_related('device', 'cable')
filterset = filters.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
- table = tables.PowerPortDetailTable
+ table = tables.PowerPortTable
action_buttons = ('import', 'export')
-class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_powerport'
- model = PowerPort
+class PowerPortView(ObjectView):
+ queryset = PowerPort.objects.all()
+
+
+class PowerPortCreateView(ComponentCreateView):
+ queryset = PowerPort.objects.all()
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
template_name = 'dcim/device_component_add.html'
-class PowerPortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_powerport'
- model = PowerPort
+class PowerPortEditView(ObjectEditView):
+ queryset = PowerPort.objects.all()
model_form = forms.PowerPortForm
-class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_powerport'
- model = PowerPort
+class PowerPortDeleteView(ObjectDeleteView):
+ queryset = PowerPort.objects.all()
-class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_powerport'
+class PowerPortBulkImportView(BulkImportView):
+ queryset = PowerPort.objects.all()
model_form = forms.PowerPortCSVForm
- table = tables.PowerPortImportTable
- default_return_url = 'dcim:powerport_list'
+ table = tables.PowerPortTable
-class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_powerport'
+class PowerPortBulkEditView(BulkEditView):
queryset = PowerPort.objects.all()
filterset = filters.PowerPortFilterSet
table = tables.PowerPortTable
form = forms.PowerPortBulkEditForm
-class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_powerport'
+class PowerPortBulkRenameView(BulkRenameView):
+ queryset = PowerPort.objects.all()
+
+
+class PowerPortBulkDisconnectView(BulkDisconnectView):
+ queryset = PowerPort.objects.all()
+
+
+class PowerPortBulkDeleteView(BulkDeleteView):
queryset = PowerPort.objects.all()
filterset = filters.PowerPortFilterSet
table = tables.PowerPortTable
- default_return_url = 'dcim:powerport_list'
#
# Power outlets
#
-class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_poweroutlet'
- queryset = PowerOutlet.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
+class PowerOutletListView(ObjectListView):
+ queryset = PowerOutlet.objects.prefetch_related('device', 'cable')
filterset = filters.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
- table = tables.PowerOutletDetailTable
+ table = tables.PowerOutletTable
action_buttons = ('import', 'export')
-class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_poweroutlet'
- model = PowerOutlet
+class PowerOutletView(ObjectView):
+ queryset = PowerOutlet.objects.all()
+
+
+class PowerOutletCreateView(ComponentCreateView):
+ queryset = PowerOutlet.objects.all()
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
template_name = 'dcim/device_component_add.html'
-class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_poweroutlet'
- model = PowerOutlet
+class PowerOutletEditView(ObjectEditView):
+ queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletForm
-class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_poweroutlet'
- model = PowerOutlet
+class PowerOutletDeleteView(ObjectDeleteView):
+ queryset = PowerOutlet.objects.all()
-class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_poweroutlet'
+class PowerOutletBulkImportView(BulkImportView):
+ queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletCSVForm
- table = tables.PowerOutletImportTable
- default_return_url = 'dcim:poweroutlet_list'
+ table = tables.PowerOutletTable
-class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_poweroutlet'
+class PowerOutletBulkEditView(BulkEditView):
queryset = PowerOutlet.objects.all()
filterset = filters.PowerOutletFilterSet
table = tables.PowerOutletTable
form = forms.PowerOutletBulkEditForm
-class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_poweroutlet'
+class PowerOutletBulkRenameView(BulkRenameView):
queryset = PowerOutlet.objects.all()
- form = forms.PowerOutletBulkRenameForm
-class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_poweroutlet'
- model = PowerOutlet
- form = forms.PowerOutletBulkDisconnectForm
+class PowerOutletBulkDisconnectView(BulkDisconnectView):
+ queryset = PowerOutlet.objects.all()
-class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_poweroutlet'
+class PowerOutletBulkDeleteView(BulkDeleteView):
queryset = PowerOutlet.objects.all()
filterset = filters.PowerOutletFilterSet
table = tables.PowerOutletTable
- default_return_url = 'dcim:poweroutlet_list'
#
# Interfaces
#
-class InterfaceListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_interface'
- queryset = Interface.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
+class InterfaceListView(ObjectListView):
+ queryset = Interface.objects.prefetch_related('device', 'cable')
filterset = filters.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
- table = tables.InterfaceDetailTable
+ table = tables.InterfaceTable
action_buttons = ('import', 'export')
-class InterfaceView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_interface'
+class InterfaceView(ObjectView):
+ queryset = Interface.objects.all()
def get(self, request, pk):
- interface = get_object_or_404(Interface, pk=pk)
+ interface = get_object_or_404(self.queryset, pk=pk)
# Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable(
- data=interface.ip_addresses.prefetch_related('vrf', 'tenant'),
+ data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
orderable=False
)
@@ -1589,7 +1473,7 @@ class InterfaceView(PermissionRequiredMixin, View):
if interface.untagged_vlan is not None:
vlans.append(interface.untagged_vlan)
vlans[0].tagged = False
- for vlan in interface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'):
+ for vlan in interface.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
@@ -1599,7 +1483,7 @@ class InterfaceView(PermissionRequiredMixin, View):
)
return render(request, 'dcim/interface.html', {
- 'interface': interface,
+ 'instance': interface,
'connected_interface': interface._connected_interface,
'connected_circuittermination': interface._connected_circuittermination,
'ipaddress_table': ipaddress_table,
@@ -1607,235 +1491,205 @@ class InterfaceView(PermissionRequiredMixin, View):
})
-class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_interface'
- model = Interface
+class InterfaceCreateView(ComponentCreateView):
+ queryset = Interface.objects.all()
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
template_name = 'dcim/device_component_add.html'
-class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_interface'
- model = Interface
+class InterfaceEditView(ObjectEditView):
+ queryset = Interface.objects.all()
model_form = forms.InterfaceForm
template_name = 'dcim/interface_edit.html'
-class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_interface'
- model = Interface
+class InterfaceDeleteView(ObjectDeleteView):
+ queryset = Interface.objects.all()
-class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_interface'
+class InterfaceBulkImportView(BulkImportView):
+ queryset = Interface.objects.all()
model_form = forms.InterfaceCSVForm
- table = tables.InterfaceImportTable
- default_return_url = 'dcim:interface_list'
+ table = tables.InterfaceTable
-class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_interface'
+class InterfaceBulkEditView(BulkEditView):
queryset = Interface.objects.all()
filterset = filters.InterfaceFilterSet
table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm
-class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_interface'
+class InterfaceBulkRenameView(BulkRenameView):
queryset = Interface.objects.all()
- form = forms.InterfaceBulkRenameForm
-class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_interface'
- model = Interface
- form = forms.InterfaceBulkDisconnectForm
+class InterfaceBulkDisconnectView(BulkDisconnectView):
+ queryset = Interface.objects.all()
-class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_interface'
+class InterfaceBulkDeleteView(BulkDeleteView):
queryset = Interface.objects.all()
filterset = filters.InterfaceFilterSet
table = tables.InterfaceTable
- default_return_url = 'dcim:interface_list'
#
# Front ports
#
-class FrontPortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_frontport'
- queryset = FrontPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
+class FrontPortListView(ObjectListView):
+ queryset = FrontPort.objects.prefetch_related('device', 'cable')
filterset = filters.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
- table = tables.FrontPortDetailTable
+ table = tables.FrontPortTable
action_buttons = ('import', 'export')
-class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_frontport'
- model = FrontPort
+class FrontPortView(ObjectView):
+ queryset = FrontPort.objects.all()
+
+
+class FrontPortCreateView(ComponentCreateView):
+ queryset = FrontPort.objects.all()
form = forms.FrontPortCreateForm
model_form = forms.FrontPortForm
template_name = 'dcim/device_component_add.html'
-class FrontPortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_frontport'
- model = FrontPort
+class FrontPortEditView(ObjectEditView):
+ queryset = FrontPort.objects.all()
model_form = forms.FrontPortForm
-class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_frontport'
- model = FrontPort
+class FrontPortDeleteView(ObjectDeleteView):
+ queryset = FrontPort.objects.all()
-class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_frontport'
+class FrontPortBulkImportView(BulkImportView):
+ queryset = FrontPort.objects.all()
model_form = forms.FrontPortCSVForm
- table = tables.FrontPortImportTable
- default_return_url = 'dcim:frontport_list'
+ table = tables.FrontPortTable
-class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_frontport'
+class FrontPortBulkEditView(BulkEditView):
queryset = FrontPort.objects.all()
filterset = filters.FrontPortFilterSet
table = tables.FrontPortTable
form = forms.FrontPortBulkEditForm
-class FrontPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_frontport'
+class FrontPortBulkRenameView(BulkRenameView):
queryset = FrontPort.objects.all()
- form = forms.FrontPortBulkRenameForm
-class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_frontport'
- model = FrontPort
- form = forms.FrontPortBulkDisconnectForm
+class FrontPortBulkDisconnectView(BulkDisconnectView):
+ queryset = FrontPort.objects.all()
-class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_frontport'
+class FrontPortBulkDeleteView(BulkDeleteView):
queryset = FrontPort.objects.all()
filterset = filters.FrontPortFilterSet
table = tables.FrontPortTable
- default_return_url = 'dcim:frontport_list'
#
# Rear ports
#
-class RearPortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rearport'
- queryset = RearPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
+class RearPortListView(ObjectListView):
+ queryset = RearPort.objects.prefetch_related('device', 'cable')
filterset = filters.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
- table = tables.RearPortDetailTable
+ table = tables.RearPortTable
action_buttons = ('import', 'export')
-class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_rearport'
- model = RearPort
+class RearPortView(ObjectView):
+ queryset = RearPort.objects.all()
+
+
+class RearPortCreateView(ComponentCreateView):
+ queryset = RearPort.objects.all()
form = forms.RearPortCreateForm
model_form = forms.RearPortForm
template_name = 'dcim/device_component_add.html'
-class RearPortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_rearport'
- model = RearPort
+class RearPortEditView(ObjectEditView):
+ queryset = RearPort.objects.all()
model_form = forms.RearPortForm
-class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_rearport'
- model = RearPort
+class RearPortDeleteView(ObjectDeleteView):
+ queryset = RearPort.objects.all()
-class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rearport'
+class RearPortBulkImportView(BulkImportView):
+ queryset = RearPort.objects.all()
model_form = forms.RearPortCSVForm
- table = tables.RearPortImportTable
- default_return_url = 'dcim:rearport_list'
+ table = tables.RearPortTable
-class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_rearport'
+class RearPortBulkEditView(BulkEditView):
queryset = RearPort.objects.all()
filterset = filters.RearPortFilterSet
table = tables.RearPortTable
form = forms.RearPortBulkEditForm
-class RearPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_rearport'
+class RearPortBulkRenameView(BulkRenameView):
queryset = RearPort.objects.all()
- form = forms.RearPortBulkRenameForm
-class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_rearport'
- model = RearPort
- form = forms.RearPortBulkDisconnectForm
+class RearPortBulkDisconnectView(BulkDisconnectView):
+ queryset = RearPort.objects.all()
-class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rearport'
+class RearPortBulkDeleteView(BulkDeleteView):
queryset = RearPort.objects.all()
filterset = filters.RearPortFilterSet
table = tables.RearPortTable
- default_return_url = 'dcim:rearport_list'
#
# Device bays
#
-class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_devicebay'
- queryset = DeviceBay.objects.prefetch_related(
- 'device', 'device__site', 'installed_device', 'installed_device__site'
- )
+class DeviceBayListView(ObjectListView):
+ queryset = DeviceBay.objects.prefetch_related('device', 'installed_device')
filterset = filters.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
- table = tables.DeviceBayDetailTable
+ table = tables.DeviceBayTable
action_buttons = ('import', 'export')
-class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_devicebay'
- model = DeviceBay
+class DeviceBayView(ObjectView):
+ queryset = DeviceBay.objects.all()
+
+
+class DeviceBayCreateView(ComponentCreateView):
+ queryset = DeviceBay.objects.all()
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
template_name = 'dcim/device_component_add.html'
-class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_devicebay'
- model = DeviceBay
+class DeviceBayEditView(ObjectEditView):
+ queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayForm
-class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_devicebay'
- model = DeviceBay
+class DeviceBayDeleteView(ObjectDeleteView):
+ queryset = DeviceBay.objects.all()
-class DeviceBayPopulateView(PermissionRequiredMixin, View):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayPopulateView(ObjectEditView):
+ queryset = DeviceBay.objects.all()
def get(self, request, pk):
-
- device_bay = get_object_or_404(DeviceBay, pk=pk)
+ device_bay = get_object_or_404(self.queryset, pk=pk)
form = forms.PopulateDeviceBayForm(device_bay)
return render(request, 'dcim/devicebay_populate.html', {
@@ -1845,8 +1699,7 @@ class DeviceBayPopulateView(PermissionRequiredMixin, View):
})
def post(self, request, pk):
-
- device_bay = get_object_or_404(DeviceBay, pk=pk)
+ device_bay = get_object_or_404(self.queryset, pk=pk)
form = forms.PopulateDeviceBayForm(device_bay, request.POST)
if form.is_valid():
@@ -1864,12 +1717,12 @@ class DeviceBayPopulateView(PermissionRequiredMixin, View):
})
-class DeviceBayDepopulateView(PermissionRequiredMixin, View):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayDepopulateView(ObjectEditView):
+ queryset = DeviceBay.objects.all()
def get(self, request, pk):
- device_bay = get_object_or_404(DeviceBay, pk=pk)
+ device_bay = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm()
return render(request, 'dcim/devicebay_depopulate.html', {
@@ -1880,7 +1733,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
def post(self, request, pk):
- device_bay = get_object_or_404(DeviceBay, pk=pk)
+ device_bay = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm(request.POST)
if form.is_valid():
@@ -1899,141 +1752,192 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
})
-class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_devicebay'
+class DeviceBayBulkImportView(BulkImportView):
+ queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayCSVForm
- table = tables.DeviceBayImportTable
- default_return_url = 'dcim:devicebay_list'
+ table = tables.DeviceBayTable
-class DeviceBayBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayBulkEditView(BulkEditView):
queryset = DeviceBay.objects.all()
filterset = filters.DeviceBayFilterSet
table = tables.DeviceBayTable
form = forms.DeviceBayBulkEditForm
-class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayBulkRenameView(BulkRenameView):
queryset = DeviceBay.objects.all()
- form = forms.DeviceBayBulkRenameForm
-class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_devicebay'
+class DeviceBayBulkDeleteView(BulkDeleteView):
queryset = DeviceBay.objects.all()
filterset = filters.DeviceBayFilterSet
table = tables.DeviceBayTable
- default_return_url = 'dcim:devicebay_list'
+
+
+#
+# Inventory items
+#
+
+class InventoryItemListView(ObjectListView):
+ queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
+ filterset = filters.InventoryItemFilterSet
+ filterset_form = forms.InventoryItemFilterForm
+ table = tables.InventoryItemTable
+ action_buttons = ('import', 'export')
+
+
+class InventoryItemView(ObjectView):
+ queryset = InventoryItem.objects.all()
+
+
+class InventoryItemEditView(ObjectEditView):
+ queryset = InventoryItem.objects.all()
+ model_form = forms.InventoryItemForm
+
+
+class InventoryItemCreateView(ComponentCreateView):
+ queryset = InventoryItem.objects.all()
+ form = forms.InventoryItemCreateForm
+ model_form = forms.InventoryItemForm
+ template_name = 'dcim/device_component_add.html'
+
+
+class InventoryItemDeleteView(ObjectDeleteView):
+ queryset = InventoryItem.objects.all()
+
+
+class InventoryItemBulkImportView(BulkImportView):
+ queryset = InventoryItem.objects.all()
+ model_form = forms.InventoryItemCSVForm
+ table = tables.InventoryItemTable
+
+
+class InventoryItemBulkEditView(BulkEditView):
+ queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
+ filterset = filters.InventoryItemFilterSet
+ table = tables.InventoryItemTable
+ form = forms.InventoryItemBulkEditForm
+
+
+class InventoryItemBulkRenameView(BulkRenameView):
+ queryset = InventoryItem.objects.all()
+
+
+class InventoryItemBulkDeleteView(BulkDeleteView):
+ queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
+ table = tables.InventoryItemTable
+ template_name = 'dcim/inventoryitem_bulk_delete.html'
#
# Bulk Device component creation
#
-class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_consoleport'
+class DeviceBulkAddConsolePortView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.ConsolePortBulkCreateForm
- model = ConsolePort
+ queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_consoleserverport'
+class DeviceBulkAddConsoleServerPortView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.ConsoleServerPortBulkCreateForm
- model = ConsoleServerPort
+ queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_powerport'
+class DeviceBulkAddPowerPortView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.PowerPortBulkCreateForm
- model = PowerPort
+ queryset = PowerPort.objects.all()
model_form = forms.PowerPortForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_poweroutlet'
+class DeviceBulkAddPowerOutletView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.PowerOutletBulkCreateForm
- model = PowerOutlet
+ queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_interface'
+class DeviceBulkAddInterfaceView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.InterfaceBulkCreateForm
- model = Interface
+ queryset = Interface.objects.all()
model_form = forms.InterfaceForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-# class DeviceBulkAddFrontPortView(PermissionRequiredMixin, BulkComponentCreateView):
-# permission_required = 'dcim.add_frontport'
+# class DeviceBulkAddFrontPortView(BulkComponentCreateView):
# parent_model = Device
# parent_field = 'device'
# form = forms.FrontPortBulkCreateForm
-# model = FrontPort
+# queryset = FrontPort.objects.all()
# model_form = forms.FrontPortForm
# filterset = filters.DeviceFilterSet
# table = tables.DeviceTable
# default_return_url = 'dcim:device_list'
-class DeviceBulkAddRearPortView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_rearport'
+class DeviceBulkAddRearPortView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.RearPortBulkCreateForm
- model = RearPort
+ queryset = RearPort.objects.all()
model_form = forms.RearPortForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_devicebay'
+class DeviceBulkAddDeviceBayView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.DeviceBayBulkCreateForm
- model = DeviceBay
+ queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
+class DeviceBulkAddInventoryItemView(BulkComponentCreateView):
+ parent_model = Device
+ parent_field = 'device'
+ form = forms.InventoryItemBulkCreateForm
+ queryset = InventoryItem.objects.all()
+ model_form = forms.InventoryItemForm
+ filterset = filters.DeviceFilterSet
+ table = tables.DeviceTable
+ default_return_url = 'dcim:device_list'
+
+
#
# Cables
#
-class CableListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_cable'
+class CableListView(ObjectListView):
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
)
@@ -2043,27 +1947,33 @@ class CableListView(PermissionRequiredMixin, ObjectListView):
action_buttons = ('import', 'export')
-class CableView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_cable'
+class CableView(ObjectView):
+ queryset = Cable.objects.all()
def get(self, request, pk):
- cable = get_object_or_404(Cable, pk=pk)
+ cable = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/cable.html', {
'cable': cable,
})
-class CableTraceView(PermissionRequiredMixin, View):
+class CableTraceView(ObjectView):
"""
Trace a cable path beginning from the given termination.
"""
- permission_required = 'dcim.view_cable'
+ additional_permissions = ['dcim.view_cable']
- def get(self, request, model, pk):
+ def dispatch(self, request, *args, **kwargs):
+ model = kwargs.pop('model')
+ self.queryset = model.objects.all()
- obj = get_object_or_404(model, pk=pk)
+ return super().dispatch(request, *args, **kwargs)
+
+ def get(self, request, pk):
+
+ obj = get_object_or_404(self.queryset, pk=pk)
path, split_ends, position_stack = obj.trace()
total_length = sum(
[entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length]
@@ -2078,23 +1988,14 @@ class CableTraceView(PermissionRequiredMixin, View):
})
-class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
- permission_required = 'dcim.add_cable'
+class CableCreateView(ObjectEditView):
+ queryset = Cable.objects.all()
template_name = 'dcim/cable_connect.html'
def dispatch(self, request, *args, **kwargs):
- termination_a_type = kwargs.get('termination_a_type')
- termination_a_id = kwargs.get('termination_a_id')
-
- 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 = {
+ # Set the model_form class based on the type of component being connected
+ self.model_form = {
'console-port': forms.ConnectCableToConsolePortForm,
'console-server-port': forms.ConnectCableToConsoleServerPortForm,
'power-port': forms.ConnectCableToPowerPortForm,
@@ -2104,106 +2005,79 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
'rear-port': forms.ConnectCableToRearPortForm,
'power-feed': forms.ConnectCableToPowerFeedForm,
'circuit-termination': forms.ConnectCableToCircuitTerminationForm,
- }[termination_b_type_name]
+ }[kwargs.get('termination_b_type')]
return super().dispatch(request, *args, **kwargs)
+ def alter_obj(self, obj, request, url_args, url_kwargs):
+ termination_a_type = url_kwargs.get('termination_a_type')
+ termination_a_id = url_kwargs.get('termination_a_id')
+ termination_b_type_name = url_kwargs.get('termination_b_type')
+ self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', ''))
+
+ # Initialize Cable termination attributes
+ obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
+ obj.termination_b_type = self.termination_b_type
+
+ return obj
+
def get(self, request, *args, **kwargs):
+ obj = self.alter_obj(self.get_object(kwargs), 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}
# Set initial site and rack based on side A termination (if not already set)
if 'termination_b_site' not in initial_data:
- initial_data['termination_b_site'] = getattr(self.obj.termination_a.parent, 'site', None)
+ initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None)
if 'termination_b_rack' not in initial_data:
- initial_data['termination_b_rack'] = getattr(self.obj.termination_a.parent, 'rack', None)
+ initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
- form = self.form_class(instance=self.obj, initial=initial_data)
+ form = self.model_form(instance=obj, initial=initial_data)
return render(request, self.template_name, {
- 'obj': self.obj,
+ 'obj': obj,
'obj_type': Cable._meta.verbose_name,
'termination_b_type': self.termination_b_type.name,
'form': form,
- 'return_url': self.get_return_url(request, self.obj),
- })
-
- def post(self, request, *args, **kwargs):
-
- form = self.form_class(request.POST, request.FILES, instance=self.obj)
-
- if form.is_valid():
- obj = form.save()
-
- msg = 'Created cable {}'.format(
- obj.get_absolute_url(),
- escape(obj)
- )
- messages.success(request, mark_safe(msg))
-
- if '_addanother' in request.POST:
- return redirect(request.get_full_path())
-
- return_url = form.cleaned_data.get('return_url')
- if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
- return redirect(return_url)
- else:
- return redirect(self.get_return_url(request, obj))
-
- return render(request, self.template_name, {
- 'obj': self.obj,
- 'obj_type': Cable._meta.verbose_name,
- 'termination_b_type': self.termination_b_type.name,
- 'form': form,
- 'return_url': self.get_return_url(request, self.obj),
+ 'return_url': self.get_return_url(request, obj),
})
-class CableEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_cable'
- model = Cable
+class CableEditView(ObjectEditView):
+ queryset = Cable.objects.all()
model_form = forms.CableForm
template_name = 'dcim/cable_edit.html'
- default_return_url = 'dcim:cable_list'
-class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_cable'
- model = Cable
- default_return_url = 'dcim:cable_list'
+class CableDeleteView(ObjectDeleteView):
+ queryset = Cable.objects.all()
-class CableBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_cable'
+class CableBulkImportView(BulkImportView):
+ queryset = Cable.objects.all()
model_form = forms.CableCSVForm
table = tables.CableTable
- default_return_url = 'dcim:cable_list'
-class CableBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_cable'
+class CableBulkEditView(BulkEditView):
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
filterset = filters.CableFilterSet
table = tables.CableTable
form = forms.CableBulkEditForm
- default_return_url = 'dcim:cable_list'
-class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_cable'
+class CableBulkDeleteView(BulkDeleteView):
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
filterset = filters.CableFilterSet
table = tables.CableTable
- default_return_url = 'dcim:cable_list'
#
# Connections
#
-class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
- permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
+class ConsoleConnectionsListView(ObjectListView):
queryset = ConsolePort.objects.prefetch_related(
'device', 'connected_endpoint__device'
).filter(
@@ -2234,8 +2108,7 @@ class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
return '\n'.join(csv_data)
-class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
- permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
+class PowerConnectionsListView(ObjectListView):
queryset = PowerPort.objects.prefetch_related(
'device', '_connected_poweroutlet__device'
).filter(
@@ -2266,8 +2139,7 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
return '\n'.join(csv_data)
-class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_interface'
+class InterfaceConnectionsListView(ObjectListView):
queryset = Interface.objects.prefetch_related(
'device', 'cable', '_connected_interface__device'
).filter(
@@ -2302,147 +2174,47 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
return '\n'.join(csv_data)
-#
-# Inventory items
-#
-
-class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_inventoryitem'
- queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
- filterset = filters.InventoryItemFilterSet
- filterset_form = forms.InventoryItemFilterForm
- table = tables.InventoryItemTable
- action_buttons = ('import', 'export')
-
-
-class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_inventoryitem'
- model = InventoryItem
- model_form = forms.InventoryItemForm
-
-
-class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_inventoryitem'
- model = InventoryItem
- form = forms.InventoryItemCreateForm
- model_form = forms.InventoryItemForm
- template_name = 'dcim/device_component_add.html'
-
-
-class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_inventoryitem'
- model = InventoryItem
-
-
-class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_inventoryitem'
- model_form = forms.InventoryItemCSVForm
- table = tables.InventoryItemTable
- default_return_url = 'dcim:inventoryitem_list'
-
-
-class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_inventoryitem'
- queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
- filterset = filters.InventoryItemFilterSet
- table = tables.InventoryItemTable
- form = forms.InventoryItemBulkEditForm
- default_return_url = 'dcim:inventoryitem_list'
-
-
-class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_inventoryitem'
- queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
- table = tables.InventoryItemTable
- template_name = 'dcim/inventoryitem_bulk_delete.html'
- default_return_url = 'dcim:inventoryitem_list'
-
-
#
# Virtual chassis
#
-class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_virtualchassis'
- queryset = VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members'))
+class VirtualChassisListView(ObjectListView):
+ queryset = VirtualChassis.objects.prefetch_related('master').annotate(
+ member_count=Count('members', distinct=True)
+ ).order_by(*VirtualChassis._meta.ordering)
table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet
filterset_form = forms.VirtualChassisFilterForm
- action_buttons = ('export',)
-class VirtualChassisView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_virtualchassis'
+class VirtualChassisView(ObjectView):
+ queryset = VirtualChassis.objects.all()
def get(self, request, pk):
- virtualchassis = get_object_or_404(VirtualChassis.objects.prefetch_related('members'), pk=pk)
+ virtualchassis = get_object_or_404(self.queryset, pk=pk)
+ members = Device.objects.restrict(request.user).filter(virtual_chassis=virtualchassis)
return render(request, 'dcim/virtualchassis.html', {
'virtualchassis': virtualchassis,
+ 'members': members,
})
-class VirtualChassisCreateView(PermissionRequiredMixin, View):
- permission_required = 'dcim.add_virtualchassis'
-
- def post(self, request):
-
- # Get the list of devices being added to a VirtualChassis
- pk_form = forms.DeviceSelectionForm(request.POST)
- pk_form.full_clean()
- if not pk_form.cleaned_data.get('pk'):
- messages.warning(request, "No devices were selected.")
- return redirect('dcim:device_list')
- device_queryset = Device.objects.filter(
- pk__in=pk_form.cleaned_data.get('pk')
- ).prefetch_related('rack').order_by('vc_position')
-
- VCMemberFormSet = modelformset_factory(
- model=Device,
- formset=forms.BaseVCMemberFormSet,
- form=forms.DeviceVCMembershipForm,
- extra=0
- )
-
- if '_create' in request.POST:
-
- vc_form = forms.VirtualChassisForm(request.POST)
- vc_form.fields['master'].queryset = device_queryset
- formset = VCMemberFormSet(request.POST, queryset=device_queryset)
-
- if vc_form.is_valid() and formset.is_valid():
-
- with transaction.atomic():
-
- # Assign each device to the VirtualChassis before saving
- virtual_chassis = vc_form.save()
- devices = formset.save(commit=False)
- for device in devices:
- device.virtual_chassis = virtual_chassis
- device.save()
-
- return redirect(vc_form.cleaned_data['master'].get_absolute_url())
-
- else:
-
- vc_form = forms.VirtualChassisForm()
- vc_form.fields['master'].queryset = device_queryset
- formset = VCMemberFormSet(queryset=device_queryset)
-
- return render(request, 'dcim/virtualchassis_edit.html', {
- 'pk_form': pk_form,
- 'vc_form': vc_form,
- 'formset': formset,
- 'return_url': reverse('dcim:device_list'),
- })
+class VirtualChassisCreateView(ObjectEditView):
+ queryset = VirtualChassis.objects.all()
+ model_form = forms.VirtualChassisCreateForm
+ template_name = 'dcim/virtualchassis_add.html'
-class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
- permission_required = 'dcim.change_virtualchassis'
+class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View):
+ queryset = VirtualChassis.objects.all()
+
+ def get_required_permission(self):
+ return 'dcim.change_virtualchassis'
def get(self, request, pk):
- virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
+ virtual_chassis = get_object_or_404(self.queryset, pk=pk)
VCMemberFormSet = modelformset_factory(
model=Device,
form=forms.DeviceVCMembershipForm,
@@ -2463,7 +2235,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
def post(self, request, pk):
- virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
+ virtual_chassis = get_object_or_404(self.queryset, pk=pk)
VCMemberFormSet = modelformset_factory(
model=Device,
form=forms.DeviceVCMembershipForm,
@@ -2493,7 +2265,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
for member in members:
member.save()
- return redirect(vc_form.cleaned_data['master'].get_absolute_url())
+ return redirect(virtual_chassis.get_absolute_url())
return render(request, 'dcim/virtualchassis_edit.html', {
'vc_form': vc_form,
@@ -2502,18 +2274,19 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
})
-class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_virtualchassis'
- model = VirtualChassis
- default_return_url = 'dcim:device_list'
+class VirtualChassisDeleteView(ObjectDeleteView):
+ queryset = VirtualChassis.objects.all()
-class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, View):
- permission_required = 'dcim.change_virtualchassis'
+class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View):
+ queryset = VirtualChassis.objects.all()
+
+ def get_required_permission(self):
+ return 'dcim.change_virtualchassis'
def get(self, request, pk):
- virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
+ virtual_chassis = get_object_or_404(self.queryset, pk=pk)
initial_data = {k: request.GET[k] for k in request.GET}
member_select_form = forms.VCMemberSelectForm(initial=initial_data)
@@ -2528,7 +2301,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
def post(self, request, pk):
- virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
+ virtual_chassis = get_object_or_404(self.queryset, pk=pk)
member_select_form = forms.VCMemberSelectForm(request.POST)
@@ -2562,12 +2335,15 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
})
-class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, View):
- permission_required = 'dcim.change_virtualchassis'
+class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View):
+ queryset = Device.objects.all()
+
+ def get_required_permission(self):
+ return 'dcim.change_device'
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False)
+ device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
form = ConfirmationForm(initial=request.GET)
return render(request, 'dcim/virtualchassis_remove_member.html', {
@@ -2578,7 +2354,7 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
def post(self, request, pk):
- device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False)
+ device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
form = ConfirmationForm(request.POST)
# Protect master device from being removed
@@ -2609,47 +2385,49 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
})
-class VirtualChassisBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_virtualchassis'
+class VirtualChassisBulkImportView(BulkImportView):
+ queryset = VirtualChassis.objects.all()
+ model_form = forms.VirtualChassisCSVForm
+ table = tables.VirtualChassisTable
+
+
+class VirtualChassisBulkEditView(BulkEditView):
queryset = VirtualChassis.objects.all()
filterset = filters.VirtualChassisFilterSet
table = tables.VirtualChassisTable
form = forms.VirtualChassisBulkEditForm
- default_return_url = 'dcim:virtualchassis_list'
-class VirtualChassisBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_virtualchassis'
+class VirtualChassisBulkDeleteView(BulkDeleteView):
queryset = VirtualChassis.objects.all()
filterset = filters.VirtualChassisFilterSet
table = tables.VirtualChassisTable
- default_return_url = 'dcim:virtualchassis_list'
#
# Power panels
#
-class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_powerpanel'
+class PowerPanelListView(ObjectListView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=Count('powerfeeds')
- )
+ ).order_by(*PowerPanel._meta.ordering)
filterset = filters.PowerPanelFilterSet
filterset_form = forms.PowerPanelFilterForm
table = tables.PowerPanelTable
-class PowerPanelView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_powerpanel'
+class PowerPanelView(ObjectView):
+ queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
def get(self, request, pk):
- powerpanel = get_object_or_404(PowerPanel.objects.prefetch_related('site', 'rack_group'), pk=pk)
+ powerpanel = get_object_or_404(self.queryset, pk=pk)
+ power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=powerpanel).prefetch_related('rack')
powerfeed_table = tables.PowerFeedTable(
- data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'),
+ data=power_feeds,
orderable=False
)
powerfeed_table.exclude = ['power_panel']
@@ -2660,57 +2438,43 @@ class PowerPanelView(PermissionRequiredMixin, View):
})
-class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_powerpanel'
- model = PowerPanel
+class PowerPanelEditView(ObjectEditView):
+ queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelForm
- default_return_url = 'dcim:powerpanel_list'
-class PowerPanelEditView(PowerPanelCreateView):
- permission_required = 'dcim.change_powerpanel'
+class PowerPanelDeleteView(ObjectDeleteView):
+ queryset = PowerPanel.objects.all()
-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'
+class PowerPanelBulkImportView(BulkImportView):
+ queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelCSVForm
table = tables.PowerPanelTable
- default_return_url = 'dcim:powerpanel_list'
-class PowerPanelBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_powerpanel'
+class PowerPanelBulkEditView(BulkEditView):
queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
filterset = filters.PowerPanelFilterSet
table = tables.PowerPanelTable
form = forms.PowerPanelBulkEditForm
- default_return_url = 'dcim:powerpanel_list'
-class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_powerpanel'
+class PowerPanelBulkDeleteView(BulkDeleteView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
rack_count=Count('powerfeeds')
- )
+ ).order_by(*PowerPanel._meta.ordering)
filterset = filters.PowerPanelFilterSet
table = tables.PowerPanelTable
- default_return_url = 'dcim:powerpanel_list'
#
# Power feeds
#
-class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_powerfeed'
+class PowerFeedListView(ObjectListView):
queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack'
)
@@ -2719,55 +2483,42 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
table = tables.PowerFeedTable
-class PowerFeedView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_powerfeed'
+class PowerFeedView(ObjectView):
+ queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
def get(self, request, pk):
- powerfeed = get_object_or_404(PowerFeed.objects.prefetch_related('power_panel', 'rack'), pk=pk)
+ powerfeed = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/powerfeed.html', {
'powerfeed': powerfeed,
})
-class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_powerfeed'
- model = PowerFeed
+class PowerFeedEditView(ObjectEditView):
+ queryset = PowerFeed.objects.all()
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(ObjectDeleteView):
+ queryset = PowerFeed.objects.all()
-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'
+class PowerFeedBulkImportView(BulkImportView):
+ queryset = PowerFeed.objects.all()
model_form = forms.PowerFeedCSVForm
table = tables.PowerFeedTable
- default_return_url = 'dcim:powerfeed_list'
-class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_powerfeed'
+class PowerFeedBulkEditView(BulkEditView):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filterset = filters.PowerFeedFilterSet
table = tables.PowerFeedTable
form = forms.PowerFeedBulkEditForm
- default_return_url = 'dcim:powerfeed_list'
-class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_powerfeed'
+class PowerFeedBulkDeleteView(BulkDeleteView):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filterset = filters.PowerFeedFilterSet
table = tables.PowerFeedTable
- default_return_url = 'dcim:powerfeed_list'
diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py
index 808d7ce32..a198d03d5 100644
--- a/netbox/extras/admin.py
+++ b/netbox/extras/admin.py
@@ -2,8 +2,7 @@ from django import forms
from django.contrib import admin
from utilities.forms import LaxURLField
-from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, Webhook
-from .reports import get_report
+from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook
def order_content_types(field):
@@ -160,6 +159,10 @@ class GraphForm(forms.ModelForm):
class Meta:
model = Graph
exclude = ()
+ help_texts = {
+ 'template_language': "Jinja2 is strongly recommended for "
+ "new graphs."
+ }
widgets = {
'source': forms.Textarea,
'link': forms.Textarea,
@@ -195,6 +198,11 @@ class ExportTemplateForm(forms.ModelForm):
class Meta:
model = ExportTemplate
exclude = []
+ help_texts = {
+ 'template_language': "Warning: Support for Django templating will be dropped in NetBox "
+ "v2.10. Jinja2 is strongly "
+ "recommended."
+ }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -228,27 +236,18 @@ class ExportTemplateAdmin(admin.ModelAdmin):
# Reports
#
-@admin.register(ReportResult)
-class ReportResultAdmin(admin.ModelAdmin):
+@admin.register(JobResult)
+class JobResultAdmin(admin.ModelAdmin):
list_display = [
- 'report', 'active', 'created', 'user', 'passing',
+ 'obj_type', 'name', 'created', 'completed', 'user', 'status',
]
fields = [
- 'report', 'user', 'passing', 'data',
+ 'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
]
list_filter = [
- 'failed',
+ 'status',
]
readonly_fields = fields
def has_add_permission(self, request):
return False
-
- def active(self, obj):
- module, report_name = obj.report.split('.')
- return True if get_report(module, report_name) else False
- active.boolean = True
-
- def passing(self, obj):
- return not obj.failed
- passing.boolean = True
diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py
index 34e865530..5ef983977 100644
--- a/netbox/extras/api/customfields.py
+++ b/netbox/extras/api/customfields.py
@@ -176,13 +176,12 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
def create(self, validated_data):
- custom_fields = validated_data.pop('custom_fields', None)
-
with transaction.atomic():
instance = super().create(validated_data)
# Save custom fields
+ custom_fields = validated_data.get('custom_fields')
if custom_fields is not None:
self._save_custom_fields(instance, custom_fields)
instance.custom_fields = custom_fields
@@ -191,10 +190,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
def update(self, instance, validated_data):
- custom_fields = validated_data.pop('custom_fields', None)
-
with transaction.atomic():
+ custom_fields = validated_data.get('custom_fields')
+ instance._cf = custom_fields
+
instance = super().update(instance, validated_data)
# Save custom fields
diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py
index 672b10a78..198a5d2f8 100644
--- a/netbox/extras/api/nested_serializers.py
+++ b/netbox/extras/api/nested_serializers.py
@@ -1,13 +1,15 @@
from rest_framework import serializers
-from extras import models
-from utilities.api import WritableNestedSerializer
+from extras import choices, models
+from users.api.nested_serializers import NestedUserSerializer
+from utilities.api import ChoiceField, WritableNestedSerializer
__all__ = [
'NestedConfigContextSerializer',
'NestedExportTemplateSerializer',
'NestedGraphSerializer',
- 'NestedReportResultSerializer',
+ 'NestedImageAttachmentSerializer',
+ 'NestedJobResultSerializer',
'NestedTagSerializer',
]
@@ -36,22 +38,29 @@ class NestedGraphSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name']
+class NestedImageAttachmentSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
+
+ class Meta:
+ model = models.ImageAttachment
+ fields = ['id', 'url', 'name', 'image']
+
+
class NestedTagSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
- tagged_items = serializers.IntegerField(read_only=True)
class Meta:
model = models.Tag
- fields = ['id', 'url', 'name', 'slug', 'color', 'tagged_items']
+ fields = ['id', 'url', 'name', 'slug', 'color']
-class NestedReportResultSerializer(serializers.ModelSerializer):
- url = serializers.HyperlinkedIdentityField(
- view_name='extras-api:report-detail',
- lookup_field='report',
- lookup_url_kwarg='pk'
+class NestedJobResultSerializer(serializers.ModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
+ status = ChoiceField(choices=choices.JobResultStatusChoices)
+ user = NestedUserSerializer(
+ read_only=True
)
class Meta:
- model = models.ReportResult
- fields = ['url', 'created', 'user', 'failed']
+ model = models.JobResult
+ fields = ['url', 'created', 'completed', 'user', 'status']
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index 54c0650da..aa8f6ba69 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -9,9 +9,8 @@ from dcim.api.nested_serializers import (
)
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.choices import *
-from extras.constants import *
from extras.models import (
- ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
+ ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
)
from extras.utils import FeatureQuery
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
@@ -31,13 +30,14 @@ from .nested_serializers import *
#
class GraphSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('graphs').get_query()),
)
class Meta:
model = Graph
- fields = ['id', 'type', 'weight', 'name', 'template_language', 'source', 'link']
+ fields = ['id', 'url', 'type', 'weight', 'name', 'template_language', 'source', 'link']
class RenderedGraphSerializer(serializers.ModelSerializer):
@@ -67,6 +67,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
#
class ExportTemplateSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
content_type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
)
@@ -78,7 +79,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
fields = [
- 'id', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
+ 'id', 'url', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
'file_extension',
]
@@ -88,11 +89,38 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
#
class TagSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
tagged_items = serializers.IntegerField(read_only=True)
class Meta:
model = Tag
- fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items']
+ fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items']
+
+
+class TaggedObjectSerializer(serializers.Serializer):
+ tags = NestedTagSerializer(many=True, required=False)
+
+ def create(self, validated_data):
+ tags = validated_data.pop('tags', [])
+ instance = super().create(validated_data)
+
+ return self._save_tags(instance, tags)
+
+ def update(self, instance, validated_data):
+ tags = validated_data.pop('tags', [])
+
+ # Cache tags on instance for change logging
+ instance._tags = tags
+
+ instance = super().update(instance, validated_data)
+
+ return self._save_tags(instance, tags)
+
+ def _save_tags(self, instance, tags):
+ if tags:
+ instance.tags.set(*[t.name for t in tags])
+
+ return instance
#
@@ -100,6 +128,7 @@ class TagSerializer(ValidatedModelSerializer):
#
class ImageAttachmentSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
content_type = ContentTypeField(
queryset=ContentType.objects.all()
)
@@ -108,7 +137,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
class Meta:
model = ImageAttachment
fields = [
- 'id', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created',
+ 'id', 'url', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width',
+ 'created',
]
def validate(self, data):
@@ -147,6 +177,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
#
class ConfigContextSerializer(ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
regions = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=NestedRegionSerializer,
@@ -205,8 +236,29 @@ class ConfigContextSerializer(ValidatedModelSerializer):
class Meta:
model = ConfigContext
fields = [
- 'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
- 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
+ 'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
+ 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
+ ]
+
+
+#
+# Job Results
+#
+
+class JobResultSerializer(serializers.ModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
+ user = NestedUserSerializer(
+ read_only=True
+ )
+ status = ChoiceField(choices=JobResultStatusChoices, read_only=True)
+ obj_type = ContentTypeField(
+ read_only=True
+ )
+
+ class Meta:
+ model = JobResult
+ fields = [
+ 'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
]
@@ -214,23 +266,22 @@ class ConfigContextSerializer(ValidatedModelSerializer):
# Reports
#
-class ReportResultSerializer(serializers.ModelSerializer):
-
- class Meta:
- model = ReportResult
- fields = ['created', 'user', 'failed', 'data']
-
-
class ReportSerializer(serializers.Serializer):
+ url = serializers.HyperlinkedIdentityField(
+ view_name='extras-api:report-detail',
+ lookup_field='full_name',
+ lookup_url_kwarg='pk'
+ )
+ id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(max_length=255)
description = serializers.CharField(max_length=255, required=False)
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
- result = NestedReportResultSerializer()
+ result = NestedJobResultSerializer()
class ReportDetailSerializer(ReportSerializer):
- result = ReportResultSerializer()
+ result = JobResultSerializer()
#
@@ -238,19 +289,17 @@ class ReportDetailSerializer(ReportSerializer):
#
class ScriptSerializer(serializers.Serializer):
- id = serializers.SerializerMethodField(read_only=True)
- name = serializers.SerializerMethodField(read_only=True)
- description = serializers.SerializerMethodField(read_only=True)
+ url = serializers.HyperlinkedIdentityField(
+ view_name='extras-api:script-detail',
+ lookup_field='full_name',
+ lookup_url_kwarg='pk'
+ )
+ id = serializers.CharField(read_only=True, source="full_name")
+ module = serializers.CharField(max_length=255)
+ name = serializers.CharField(read_only=True)
+ description = serializers.CharField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True)
-
- def get_id(self, instance):
- return '{}.{}'.format(instance.__module__, instance.__name__)
-
- def get_name(self, instance):
- return getattr(instance.Meta, 'name', instance.__name__)
-
- def get_description(self, instance):
- return getattr(instance.Meta, 'description', '')
+ result = NestedJobResultSerializer()
def get_vars(self, instance):
return {
@@ -258,6 +307,10 @@ class ScriptSerializer(serializers.Serializer):
}
+class ScriptDetailSerializer(ScriptSerializer):
+ result = JobResultSerializer()
+
+
class ScriptInputSerializer(serializers.Serializer):
data = serializers.JSONField()
commit = serializers.BooleanField()
@@ -268,7 +321,7 @@ class ScriptLogMessageSerializer(serializers.Serializer):
message = serializers.SerializerMethodField(read_only=True)
def get_status(self, instance):
- return LOG_LEVEL_CODES.get(instance[0])
+ return instance[0]
def get_message(self, instance):
return instance[1]
@@ -284,6 +337,7 @@ class ScriptOutputSerializer(serializers.Serializer):
#
class ObjectChangeSerializer(serializers.ModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
user = NestedUserSerializer(
read_only=True
)
@@ -301,8 +355,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
class Meta:
model = ObjectChange
fields = [
- 'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
- 'changed_object', 'object_data',
+ 'id', 'url', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
+ 'changed_object_id', 'changed_object', 'object_data',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py
index 8d8463bad..9c50c9a45 100644
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -1,18 +1,9 @@
-from rest_framework import routers
-
+from utilities.api import OrderedDefaultRouter
from . import views
-class ExtrasRootView(routers.APIRootView):
- """
- Extras API root view
- """
- def get_view_name(self):
- return 'Extras'
-
-
-router = routers.DefaultRouter()
-router.APIRootView = ExtrasRootView
+router = OrderedDefaultRouter()
+router.APIRootView = views.ExtrasRootView
# Custom field choices
router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
@@ -41,5 +32,8 @@ router.register('scripts', views.ScriptViewSet, basename='script')
# Change logging
router.register('object-changes', views.ObjectChangeViewSet)
+# Job Results
+router.register('job-results', views.JobResultViewSet)
+
app_name = 'extras-api'
urlpatterns = router.urls
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 472a908a1..289a51c83 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -3,23 +3,37 @@ from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404
+from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
+from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
+from rq import Worker
from extras import filters
+from extras.choices import JobResultStatusChoices
from extras.models import (
- ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
+ ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
)
-from extras.reports import get_report, get_reports
+from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
+from utilities.exceptions import RQWorkerNotRunningException
from utilities.metadata import ContentTypeMetadata
+from utilities.utils import copy_safe_request
from . import serializers
+class ExtrasRootView(APIRootView):
+ """
+ Extras API root view
+ """
+ def get_view_name(self):
+ return 'Extras'
+
+
#
# Custom field choices
#
@@ -112,8 +126,8 @@ class ExportTemplateViewSet(ModelViewSet):
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(
- tagged_items=Count('extras_taggeditem_items', distinct=True)
- )
+ tagged_items=Count('extras_taggeditem_items')
+ ).order_by(*Tag._meta.ordering)
serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilterSet
@@ -169,13 +183,21 @@ class ReportViewSet(ViewSet):
Compile all reports and their related results (if any). Result data is deferred in the list view.
"""
report_list = []
+ report_content_type = ContentType.objects.get(app_label='extras', model='report')
+ results = {
+ r.name: r
+ for r in JobResult.objects.filter(
+ obj_type=report_content_type,
+ status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+ ).defer('data')
+ }
# Iterate through all available Reports.
for module_name, reports in get_reports():
for report in reports:
- # Attach the relevant ReportResult (if any) to each Report.
- report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first()
+ # Attach the relevant JobResult (if any) to each Report.
+ report.result = results.get(report.full_name, None)
report_list.append(report)
serializer = serializers.ReportSerializer(report_list, many=True, context={
@@ -189,29 +211,46 @@ class ReportViewSet(ViewSet):
Retrieve a single Report identified as ".".
"""
- # Retrieve the Report and ReportResult, if any.
+ # Retrieve the Report and JobResult, if any.
report = self._retrieve_report(pk)
- report.result = ReportResult.objects.filter(report=report.full_name).first()
+ report_content_type = ContentType.objects.get(app_label='extras', model='report')
+ report.result = JobResult.objects.filter(
+ obj_type=report_content_type,
+ name=report.full_name,
+ status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
+ ).first()
- serializer = serializers.ReportDetailSerializer(report)
+ serializer = serializers.ReportDetailSerializer(report, context={
+ 'request': request
+ })
return Response(serializer.data)
@action(detail=True, methods=['post'])
def run(self, request, pk):
"""
- Run a Report and create a new ReportResult, overwriting any previous result for the Report.
+ Run a Report identified as ".
+
+
```
>>> Immediately following a new release, it takes some time for CDNs to catch up and get the new versions live on the CDN.
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/02.basic-usage/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/02.basic-usage/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/02.basic-usage/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/02.basic-usage/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/03.builds-and-modules/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/03.builds-and-modules/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/03.builds-and-modules/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/03.builds-and-modules/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/chapter.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/01.getting-started/chapter.md
rename to netbox/project-static/select2-4.0.13/docs/pages/01.getting-started/chapter.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/01.getting-help/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/01.getting-help/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/01.getting-help/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/01.getting-help/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/02.common-problems/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/02.common-problems/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/02.common-problems/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/02.common-problems/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/chapter.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/02.troubleshooting/chapter.md
rename to netbox/project-static/select2-4.0.13/docs/pages/02.troubleshooting/chapter.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/03.configuration/01.options-api/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/03.configuration/01.options-api/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/03.configuration/01.options-api/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/03.configuration/01.options-api/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/03.configuration/02.defaults/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/03.configuration/02.defaults/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/03.configuration/02.defaults/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/03.configuration/02.defaults/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/03.configuration/03.data-attributes/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/03.configuration/03.data-attributes/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/03.configuration/03.data-attributes/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/03.configuration/03.data-attributes/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/03.configuration/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/03.configuration/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/03.configuration/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/03.configuration/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/04.appearance/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/04.appearance/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/04.appearance/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/04.appearance/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/05.options/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/05.options/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/05.options/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/05.options/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/01.formats/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/01.formats/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/01.formats/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/01.formats/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/02.ajax/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/02.ajax/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/02.ajax/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/02.ajax/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/03.arrays/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/03.arrays/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/03.arrays/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/03.arrays/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/chapter.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/06.data-sources/chapter.md
rename to netbox/project-static/select2-4.0.13/docs/pages/06.data-sources/chapter.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/07.dropdown/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/07.dropdown/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/07.dropdown/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/07.dropdown/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/08.selections/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/08.selections/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/08.selections/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/08.selections/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/09.tagging/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/09.tagging/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/09.tagging/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/09.tagging/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/10.placeholders/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/10.placeholders/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/10.placeholders/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/10.placeholders/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/11.searching/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/11.searching/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/11.searching/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/11.searching/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/01.add-select-clear-items/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/01.add-select-clear-items/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/01.add-select-clear-items/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/01.add-select-clear-items/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/02.retrieving-selections/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/02.retrieving-selections/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/02.retrieving-selections/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/02.retrieving-selections/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/03.methods/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/03.methods/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/03.methods/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/03.methods/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/04.events/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/04.events/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/04.events/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/04.events/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/chapter.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/12.programmatic-control/chapter.md
rename to netbox/project-static/select2-4.0.13/docs/pages/12.programmatic-control/chapter.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/13.i18n/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/13.i18n/docs.md
similarity index 98%
rename from netbox/project-static/select2-4.0.12/docs/pages/13.i18n/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/13.i18n/docs.md
index da9cef4fa..227cc9f34 100644
--- a/netbox/project-static/select2-4.0.12/docs/pages/13.i18n/docs.md
+++ b/netbox/project-static/select2-4.0.13/docs/pages/13.i18n/docs.md
@@ -7,7 +7,7 @@ process:
never_cache_twig: true
---
-{% do assets.addJs('https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/js/i18n/es.js', 90) %}
+{% do assets.addJs('https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/js/i18n/es.js', 90) %}
## Message translations
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/01.adapters-and-decorators/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/01.adapters-and-decorators/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/01.adapters-and-decorators/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/01.adapters-and-decorators/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/01.selection/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/01.selection/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/01.selection/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/01.selection/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/02.array/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/02.array/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/02.array/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/02.array/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/03.ajax/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/03.ajax/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/03.ajax/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/03.ajax/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/04.data/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/04.data/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/04.data/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/04.data/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/05.results/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/05.results/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/05.results/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/05.results/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/06.dropdown/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/06.dropdown/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/06.dropdown/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/06.dropdown/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/02.default-adapters/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/02.default-adapters/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/14.advanced/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/14.advanced/chapter.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/14.advanced/chapter.md
rename to netbox/project-static/select2-4.0.13/docs/pages/14.advanced/chapter.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/01.new-in-40/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/01.new-in-40/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/01.new-in-40/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/01.new-in-40/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/02.migrating-from-35/docs.md b/netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/02.migrating-from-35/docs.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/02.migrating-from-35/docs.md
rename to netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/02.migrating-from-35/docs.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/chapter.md b/netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/chapter.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/15.upgrading/chapter.md
rename to netbox/project-static/select2-4.0.13/docs/pages/15.upgrading/chapter.md
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ak.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ak.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ak.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ak.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/al.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/al.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/al.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/al.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ar.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ar.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ar.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ar.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/az.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/az.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/az.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/az.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ca.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ca.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ca.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ca.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/co.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/co.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/co.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/co.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ct.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ct.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ct.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ct.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/de.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/de.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/de.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/de.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/fl.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/fl.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/fl.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/fl.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ga.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ga.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ga.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ga.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/hi.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/hi.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/hi.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/hi.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ia.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ia.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ia.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ia.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/id.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/id.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/id.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/id.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/il.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/il.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/il.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/il.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/in.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/in.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/in.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/in.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ks.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ks.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ks.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ks.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ky.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ky.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ky.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ky.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/la.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/la.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/la.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/la.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ma.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ma.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ma.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ma.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/md.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/md.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/md.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/md.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/me.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/me.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/me.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/me.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/mi.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/mi.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/mi.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/mi.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/mn.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/mn.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/mn.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/mn.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/mo.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/mo.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/mo.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/mo.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ms.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ms.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ms.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ms.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/mt.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/mt.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/mt.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/mt.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nc.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nc.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nc.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nc.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nd.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nd.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nd.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nd.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ne.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ne.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ne.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ne.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nh.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nh.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nh.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nh.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nj.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nj.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nj.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nj.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nm.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nm.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nm.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nm.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/nv.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/nv.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/nv.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/nv.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ny.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ny.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ny.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ny.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/oh.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/oh.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/oh.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/oh.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ok.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ok.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ok.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ok.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/or.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/or.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/or.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/or.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/pa.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/pa.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/pa.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/pa.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ri.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ri.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ri.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ri.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/sc.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/sc.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/sc.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/sc.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/sd.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/sd.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/sd.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/sd.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/tn.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/tn.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/tn.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/tn.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/tx.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/tx.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/tx.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/tx.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/ut.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/ut.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/ut.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/ut.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/va.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/va.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/va.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/va.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/vt.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/vt.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/vt.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/vt.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/wa.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/wa.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/wa.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/wa.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/wi.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/wi.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/wi.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/wi.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/wv.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/wv.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/wv.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/wv.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/flags/wy.png b/netbox/project-static/select2-4.0.13/docs/pages/images/flags/wy.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/flags/wy.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/flags/wy.png
diff --git a/netbox/project-static/select2-4.0.12/docs/pages/images/logo.png b/netbox/project-static/select2-4.0.13/docs/pages/images/logo.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/pages/images/logo.png
rename to netbox/project-static/select2-4.0.13/docs/pages/images/logo.png
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/.gitkeep b/netbox/project-static/select2-4.0.13/docs/plugins/.gitkeep
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/.gitkeep
rename to netbox/project-static/select2-4.0.13/docs/plugins/.gitkeep
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/CHANGELOG.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/CHANGELOG.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/CHANGELOG.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/LICENSE
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/LICENSE
rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/LICENSE
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/README.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/README.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/README.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/anchors.php b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/anchors.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/anchors.php
rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/anchors.php
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/anchors.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/anchors.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/anchors.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/anchors.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/blueprints.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/blueprints.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/blueprints.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/anchors/js/anchor.min.js b/netbox/project-static/select2-4.0.13/docs/plugins/anchors/js/anchor.min.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/anchors/js/anchor.min.js
rename to netbox/project-static/select2-4.0.13/docs/plugins/anchors/js/anchor.min.js
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/CHANGELOG.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/CHANGELOG.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/CHANGELOG.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/LICENSE
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/LICENSE
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/LICENSE
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/README.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/README.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/README.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/assets/readme_1.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/assets/readme_1.png
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/assets/readme_1.png
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/blueprints.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/blueprints.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/blueprints.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/breadcrumbs.php b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/breadcrumbs.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/breadcrumbs.php
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/breadcrumbs.php
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/breadcrumbs.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/breadcrumbs.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/breadcrumbs.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/breadcrumbs.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/classes/breadcrumbs.php b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/classes/breadcrumbs.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/classes/breadcrumbs.php
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/classes/breadcrumbs.php
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/css/breadcrumbs.css b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/css/breadcrumbs.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/css/breadcrumbs.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/css/breadcrumbs.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/templates/partials/breadcrumbs.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/templates/partials/breadcrumbs.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/breadcrumbs/templates/partials/breadcrumbs.html.twig
rename to netbox/project-static/select2-4.0.13/docs/plugins/breadcrumbs/templates/partials/breadcrumbs.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/error/CHANGELOG.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/CHANGELOG.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/CHANGELOG.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/error/LICENSE
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/LICENSE
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/LICENSE
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/error/README.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/README.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/README.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/error/assets/readme_1.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/assets/readme_1.png
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/assets/readme_1.png
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/error/blueprints.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/blueprints.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/blueprints.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/cli/LogCommand.php b/netbox/project-static/select2-4.0.13/docs/plugins/error/cli/LogCommand.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/cli/LogCommand.php
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/cli/LogCommand.php
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/error.php b/netbox/project-static/select2-4.0.13/docs/plugins/error/error.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/error.php
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/error.php
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/error.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/error/error.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/error.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/error.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/languages.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/error/languages.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/languages.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/languages.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/pages/error.md b/netbox/project-static/select2-4.0.13/docs/plugins/error/pages/error.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/pages/error.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/pages/error.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/templates/error.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/error/templates/error.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/templates/error.html.twig
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/templates/error.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/error/templates/error.json.twig b/netbox/project-static/select2-4.0.13/docs/plugins/error/templates/error.json.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/error/templates/error.json.twig
rename to netbox/project-static/select2-4.0.13/docs/plugins/error/templates/error.json.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/CHANGELOG.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/CHANGELOG.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/CHANGELOG.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/LICENSE
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/LICENSE
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/LICENSE
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/README.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/README.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/README.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/assets/readme_1.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/assets/readme_1.png
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/assets/readme_1.png
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/blueprints.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/blueprints.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/blueprints.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/agate.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/agate.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/agate.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/agate.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/androidstudio.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/androidstudio.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/androidstudio.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/androidstudio.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/arduino-light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/arduino-light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/arduino-light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/arduino-light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/arta.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/arta.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/arta.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/arta.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/ascetic.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/ascetic.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/ascetic.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/ascetic.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-cave.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-cave.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-cave.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-cave.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-cave.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-cave.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-cave.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-cave.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-dune.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-dune.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-dune.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-dune.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-dune.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-dune.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-dune.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-dune.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-estuary.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-estuary.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-estuary.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-estuary.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-estuary.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-estuary.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-estuary.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-estuary.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-forest.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-forest.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-forest.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-forest.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-forest.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-forest.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-forest.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-forest.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-heath.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-heath.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-heath.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-heath.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-heath.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-heath.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-heath.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-heath.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-lakeside.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-lakeside.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-lakeside.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-lakeside.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-lakeside.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-lakeside.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-lakeside.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-lakeside.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-plateau.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-plateau.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-plateau.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-plateau.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-plateau.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-plateau.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-plateau.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-plateau.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-savanna.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-savanna.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-savanna.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-savanna.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-savanna.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-savanna.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-savanna.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-savanna.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-seaside.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-seaside.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-seaside.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-seaside.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-seaside.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-seaside.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-seaside.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-seaside.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-sulphurpool.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-sulphurpool.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-sulphurpool.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-sulphurpool.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-sulphurpool.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-sulphurpool.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/atelier-sulphurpool.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/atelier-sulphurpool.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/brown-paper.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/brown-paper.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/brown-paper.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/brown-paper.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/codepen-embed.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/codepen-embed.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/codepen-embed.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/codepen-embed.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/color-brewer.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/color-brewer.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/color-brewer.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/color-brewer.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/darkula.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/darkula.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/darkula.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/darkula.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/default.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/default.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/default.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/default.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/docco.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/docco.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/docco.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/docco.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/far.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/far.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/far.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/far.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/foundation.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/foundation.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/foundation.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/foundation.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/github-gist.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/github-gist.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/github-gist.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/github-gist.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/github.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/github.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/github.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/github.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/googlecode.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/googlecode.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/googlecode.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/googlecode.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/grayscale.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/grayscale.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/grayscale.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/grayscale.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/hopscotch.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/hopscotch.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/hopscotch.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/hopscotch.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/hybrid.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/hybrid.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/hybrid.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/hybrid.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/idea.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/idea.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/idea.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/idea.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/ir-black.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/ir-black.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/ir-black.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/ir-black.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/kimbie.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/kimbie.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/kimbie.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/kimbie.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/kimbie.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/kimbie.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/kimbie.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/kimbie.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/learn.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/learn.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/learn.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/learn.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/magula.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/magula.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/magula.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/magula.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/mono-blue.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/mono-blue.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/mono-blue.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/mono-blue.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/monokai-sublime.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/monokai-sublime.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/monokai-sublime.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/monokai-sublime.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/monokai.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/monokai.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/monokai.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/monokai.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/obsidian.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/obsidian.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/obsidian.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/obsidian.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso-dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso-dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso-dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso-dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso-light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso-light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso-light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso-light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso.dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso.dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso.dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso.dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso.light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso.light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/paraiso.light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/paraiso.light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/pojoaque.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/pojoaque.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/pojoaque.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/pojoaque.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/railscasts.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/railscasts.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/railscasts.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/railscasts.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/rainbow.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/rainbow.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/rainbow.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/rainbow.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/school-book.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/school-book.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/school-book.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/school-book.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/solarized-dark.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/solarized-dark.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/solarized-dark.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/solarized-dark.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/solarized-light.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/solarized-light.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/solarized-light.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/solarized-light.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/sunburst.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/sunburst.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/sunburst.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/sunburst.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-blue.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-blue.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-blue.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-blue.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-bright.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-bright.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-bright.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-bright.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-eighties.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-eighties.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night-eighties.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night-eighties.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow-night.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow-night.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/tomorrow.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/tomorrow.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/vs.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/vs.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/vs.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/vs.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/xcode.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/xcode.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/xcode.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/xcode.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/zenburn.css b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/zenburn.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/css/zenburn.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/css/zenburn.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/highlight.php b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/highlight.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/highlight.php
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/highlight.php
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/highlight.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/highlight.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/highlight.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/highlight.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/js/highlight.pack.js b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/js/highlight.pack.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/js/highlight.pack.js
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/js/highlight.pack.js
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/highlight/js/highlightjs-line-numbers.min.js b/netbox/project-static/select2-4.0.13/docs/plugins/highlight/js/highlightjs-line-numbers.min.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/highlight/js/highlightjs-line-numbers.min.js
rename to netbox/project-static/select2-4.0.13/docs/plugins/highlight/js/highlightjs-line-numbers.min.js
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/problems/CHANGELOG.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/CHANGELOG.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/CHANGELOG.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/problems/LICENSE
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/LICENSE
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/LICENSE
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/problems/README.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/README.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/README.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/problems/assets/readme_1.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/assets/readme_1.png
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/assets/readme_1.png
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/problems/blueprints.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/blueprints.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/blueprints.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/css/problems.css b/netbox/project-static/select2-4.0.13/docs/plugins/problems/css/problems.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/css/problems.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/css/problems.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/css/template.css b/netbox/project-static/select2-4.0.13/docs/plugins/problems/css/template.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/css/template.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/css/template.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/html/problems.html b/netbox/project-static/select2-4.0.13/docs/plugins/problems/html/problems.html
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/html/problems.html
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/html/problems.html
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/problems.php b/netbox/project-static/select2-4.0.13/docs/plugins/problems/problems.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/problems.php
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/problems.php
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/problems/problems.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/problems/problems.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/problems/problems.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/problems/problems.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/CHANGELOG.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/CHANGELOG.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/CHANGELOG.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/LICENSE b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/LICENSE
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/LICENSE
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/LICENSE
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/README.md b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/README.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/README.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/README.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/assets/readme_1.png b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/assets/readme_1.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/assets/readme_1.png
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/assets/readme_1.png
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/assets/search.svg b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/assets/search.svg
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/assets/search.svg
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/assets/search.svg
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/blueprints.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/blueprints.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/blueprints.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/css/simplesearch.css b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/css/simplesearch.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/css/simplesearch.css
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/css/simplesearch.css
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/js/simplesearch.js b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/js/simplesearch.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/js/simplesearch.js
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/js/simplesearch.js
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/languages.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/languages.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/languages.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/languages.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/pages/simplesearch.md b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/pages/simplesearch.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/pages/simplesearch.md
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/pages/simplesearch.md
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/simplesearch.php b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/simplesearch.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/simplesearch.php
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/simplesearch.php
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/simplesearch.yaml b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/simplesearch.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/simplesearch.yaml
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/simplesearch.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_base.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_base.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_base.html.twig
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_base.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_item.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_item.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_item.html.twig
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_item.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_searchbox.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_searchbox.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/partials/simplesearch_searchbox.html.twig
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/partials/simplesearch_searchbox.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/simplesearch_results.html.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/simplesearch_results.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/simplesearch_results.html.twig
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/simplesearch_results.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/simplesearch_results.json.twig b/netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/simplesearch_results.json.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/plugins/simplesearch/templates/simplesearch_results.json.twig
rename to netbox/project-static/select2-4.0.13/docs/plugins/simplesearch/templates/simplesearch_results.json.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/screenshot.jpg b/netbox/project-static/select2-4.0.13/docs/screenshot.jpg
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/screenshot.jpg
rename to netbox/project-static/select2-4.0.13/docs/screenshot.jpg
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/.gitkeep b/netbox/project-static/select2-4.0.13/docs/themes/.gitkeep
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/.gitkeep
rename to netbox/project-static/select2-4.0.13/docs/themes/.gitkeep
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/CHANGELOG.md b/netbox/project-static/select2-4.0.13/docs/themes/learn2/CHANGELOG.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/CHANGELOG.md
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/CHANGELOG.md
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/LICENSE b/netbox/project-static/select2-4.0.13/docs/themes/learn2/LICENSE
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/LICENSE
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/LICENSE
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/README.md b/netbox/project-static/select2-4.0.13/docs/themes/learn2/README.md
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/README.md
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/README.md
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints.yaml
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints/chapter.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints/chapter.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints/chapter.yaml
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints/chapter.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints/docs.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints/docs.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/blueprints/docs.yaml
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/blueprints/docs.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/nucleus.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/nucleus.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/nucleus.css
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/nucleus.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/nucleus.css.map b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/nucleus.css.map
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/nucleus.css.map
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/nucleus.css.map
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/theme.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/theme.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/theme.css
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/theme.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/theme.css.map b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/theme.css.map
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css-compiled/theme.css.map
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css-compiled/theme.css.map
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/featherlight.min.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/featherlight.min.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/featherlight.min.css
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/featherlight.min.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/font-awesome.min.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/font-awesome.min.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/font-awesome.min.css
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/font-awesome.min.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/nucleus-ie10.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/nucleus-ie10.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/nucleus-ie10.css
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/nucleus-ie10.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/nucleus-ie9.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/nucleus-ie9.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/nucleus-ie9.css
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/nucleus-ie9.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/css/pure-0.5.0/grids-min.css b/netbox/project-static/select2-4.0.13/docs/themes/learn2/css/pure-0.5.0/grids-min.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/css/pure-0.5.0/grids-min.css
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/css/pure-0.5.0/grids-min.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.eot b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.eot
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.eot
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.eot
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.svg b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.svg
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.svg
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.svg
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.ttf b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.ttf
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.ttf
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.ttf
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.woff
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.woff
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff2 b/netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.woff2
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/fonts/fontawesome-webfont.woff2
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/fonts/fontawesome-webfont.woff2
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/images/clippy.svg b/netbox/project-static/select2-4.0.13/docs/themes/learn2/images/clippy.svg
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/images/clippy.svg
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/images/clippy.svg
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/images/favicon.png b/netbox/project-static/select2-4.0.13/docs/themes/learn2/images/favicon.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/images/favicon.png
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/images/favicon.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/images/logo.png b/netbox/project-static/select2-4.0.13/docs/themes/learn2/images/logo.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/images/logo.png
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/images/logo.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/clipboard.min.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/clipboard.min.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/clipboard.min.js
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/clipboard.min.js
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/featherlight.min.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/featherlight.min.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/featherlight.min.js
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/featherlight.min.js
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/html5shiv-printshiv.min.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/html5shiv-printshiv.min.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/html5shiv-printshiv.min.js
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/html5shiv-printshiv.min.js
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/jquery.scrollbar.min.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/jquery.scrollbar.min.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/jquery.scrollbar.min.js
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/jquery.scrollbar.min.js
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/learn.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/learn.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/learn.js
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/learn.js
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/js/modernizr.custom.71422.js b/netbox/project-static/select2-4.0.13/docs/themes/learn2/js/modernizr.custom.71422.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/js/modernizr.custom.71422.js
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/js/modernizr.custom.71422.js
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/languages.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/languages.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/languages.yaml
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/languages.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/learn2.php b/netbox/project-static/select2-4.0.13/docs/themes/learn2/learn2.php
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/learn2.php
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/learn2.php
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/learn2.yaml b/netbox/project-static/select2-4.0.13/docs/themes/learn2/learn2.yaml
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/learn2.yaml
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/learn2.yaml
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/screenshot.jpg b/netbox/project-static/select2-4.0.13/docs/themes/learn2/screenshot.jpg
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/screenshot.jpg
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/screenshot.jpg
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss.sh b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss.sh
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss.sh
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss.sh
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_base.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_base.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_base.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_breakpoints.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_breakpoints.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_breakpoints.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_breakpoints.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_core.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_core.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_core.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_core.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_layout.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_layout.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_layout.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_layout.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_nav.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_nav.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_nav.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_nav.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_typography.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_typography.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/nucleus/_typography.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/nucleus/_typography.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_base.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_base.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_base.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_bullets.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_bullets.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_bullets.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_bullets.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_colors.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_colors.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/configuration/theme/_colors.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/configuration/theme/_colors.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_core.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_core.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_core.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_core.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_flex.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_flex.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_flex.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_flex.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_forms.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_forms.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_forms.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_forms.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_typography.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_typography.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/_typography.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/_typography.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_base.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_base.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_base.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_direction.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_direction.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_direction.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_direction.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_range.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_range.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/functions/_range.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/functions/_range.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_base.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_base.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_base.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_breakpoints.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_breakpoints.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_breakpoints.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_breakpoints.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_utilities.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_utilities.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/mixins/_utilities.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/mixins/_utilities.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/particles/_align-text.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/particles/_align-text.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/particles/_align-text.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/particles/_align-text.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/particles/_visibility.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/particles/_visibility.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/nucleus/particles/_visibility.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/nucleus/particles/_visibility.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_bullets.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_bullets.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_bullets.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_bullets.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_buttons.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_buttons.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_buttons.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_buttons.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_configuration.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_configuration.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_configuration.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_configuration.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_core.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_core.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_core.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_core.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_custom.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_custom.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_custom.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_custom.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_fonts.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_fonts.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_fonts.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_fonts.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_forms.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_forms.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_forms.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_forms.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_header.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_header.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_header.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_header.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_main.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_main.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_main.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_main.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_nav.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_nav.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_nav.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_nav.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_scrollbar.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_scrollbar.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_scrollbar.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_scrollbar.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_tables.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_tables.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_tables.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_tables.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_tooltips.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_tooltips.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_tooltips.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_tooltips.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_typography.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_typography.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/_typography.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/_typography.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/modules/_base.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/modules/_base.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/modules/_base.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/modules/_base.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/modules/_buttons.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/modules/_buttons.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/theme/modules/_buttons.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/theme/modules/_buttons.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/_bourbon-deprecated-upcoming.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/_bourbon-deprecated-upcoming.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/_bourbon-deprecated-upcoming.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/_bourbon-deprecated-upcoming.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/_bourbon.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/_bourbon.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/_bourbon.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/_bourbon.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_button.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_button.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_button.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_button.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_clearfix.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_clearfix.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_clearfix.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_clearfix.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_directional-values.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_directional-values.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_directional-values.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_directional-values.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_ellipsis.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_ellipsis.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_ellipsis.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_ellipsis.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_font-family.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_font-family.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_font-family.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_font-family.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_hide-text.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_hide-text.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_hide-text.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_hide-text.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_html5-input-types.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_html5-input-types.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_html5-input-types.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_html5-input-types.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_position.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_position.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_position.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_position.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_prefixer.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_prefixer.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_prefixer.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_prefixer.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_rem.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_rem.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_rem.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_rem.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_retina-image.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_retina-image.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_retina-image.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_retina-image.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_size.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_size.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_size.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_size.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_timing-functions.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_timing-functions.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_timing-functions.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_timing-functions.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_triangle.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_triangle.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_triangle.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_triangle.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_word-wrap.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_word-wrap.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/addons/_word-wrap.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/addons/_word-wrap.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_animation.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_animation.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_animation.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_animation.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_appearance.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_appearance.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_appearance.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_appearance.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_backface-visibility.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_backface-visibility.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_backface-visibility.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_backface-visibility.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_background-image.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_background-image.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_background-image.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_background-image.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_background.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_background.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_background.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_background.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_border-image.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_border-image.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_border-image.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_border-image.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_border-radius.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_border-radius.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_border-radius.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_border-radius.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_box-sizing.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_box-sizing.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_box-sizing.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_box-sizing.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_calc.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_calc.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_calc.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_calc.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_columns.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_columns.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_columns.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_columns.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_filter.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_filter.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_filter.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_filter.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_flex-box.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_flex-box.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_flex-box.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_flex-box.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_font-face.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_font-face.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_font-face.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_font-face.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_font-feature-settings.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_font-feature-settings.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_font-feature-settings.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_font-feature-settings.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_hidpi-media-query.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_hidpi-media-query.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_hidpi-media-query.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_hidpi-media-query.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_hyphens.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_hyphens.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_hyphens.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_hyphens.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_image-rendering.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_image-rendering.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_image-rendering.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_image-rendering.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_keyframes.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_keyframes.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_keyframes.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_keyframes.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_linear-gradient.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_linear-gradient.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_linear-gradient.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_linear-gradient.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_perspective.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_perspective.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_perspective.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_perspective.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_placeholder.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_placeholder.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_placeholder.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_placeholder.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_radial-gradient.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_radial-gradient.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_radial-gradient.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_radial-gradient.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_transform.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_transform.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_transform.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_transform.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_transition.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_transition.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_transition.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_transition.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_user-select.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_user-select.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/css3/_user-select.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/css3/_user-select.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_assign.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_assign.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_assign.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_assign.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_color-lightness.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_color-lightness.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_color-lightness.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_color-lightness.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_flex-grid.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_flex-grid.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_flex-grid.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_flex-grid.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_golden-ratio.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_golden-ratio.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_golden-ratio.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_golden-ratio.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_grid-width.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_grid-width.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_grid-width.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_grid-width.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_modular-scale.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_modular-scale.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_modular-scale.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_modular-scale.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-em.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-em.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-em.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-em.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-rem.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-rem.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-rem.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_px-to-rem.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_strip-units.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_strip-units.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_strip-units.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_strip-units.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_tint-shade.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_tint-shade.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_tint-shade.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_tint-shade.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_transition-property-name.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_transition-property-name.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_transition-property-name.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_transition-property-name.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_unpack.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_unpack.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/functions/_unpack.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/functions/_unpack.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_convert-units.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_convert-units.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_convert-units.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_convert-units.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_gradient-positions-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_gradient-positions-parser.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_gradient-positions-parser.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_gradient-positions-parser.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_is-num.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_is-num.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_is-num.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_is-num.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-angle-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-angle-parser.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-angle-parser.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-angle-parser.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-gradient-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-gradient-parser.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-gradient-parser.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-gradient-parser.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-positions-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-positions-parser.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-positions-parser.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-positions-parser.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-side-corner-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-side-corner-parser.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-side-corner-parser.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_linear-side-corner-parser.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-arg-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-arg-parser.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-arg-parser.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-arg-parser.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-gradient-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-gradient-parser.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-gradient-parser.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-gradient-parser.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-positions-parser.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-positions-parser.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-positions-parser.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_radial-positions-parser.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_render-gradients.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_render-gradients.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_render-gradients.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_render-gradients.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_shape-size-stripper.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_shape-size-stripper.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_shape-size-stripper.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_shape-size-stripper.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_str-to-num.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_str-to-num.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/helpers/_str-to-num.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/helpers/_str-to-num.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/settings/_prefixer.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/settings/_prefixer.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/settings/_prefixer.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/settings/_prefixer.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/settings/_px-to-em.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/settings/_px-to-em.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/bourbon/settings/_px-to-em.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/bourbon/settings/_px-to-em.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/_color-schemer.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/_color-schemer.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/_color-schemer.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/_color-schemer.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_cmyk.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_cmyk.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_cmyk.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_cmyk.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-adjustments.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-adjustments.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-adjustments.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-adjustments.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-schemer.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-schemer.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-schemer.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_color-schemer.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_colorblind.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_colorblind.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_colorblind.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_colorblind.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_comparison.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_comparison.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_comparison.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_comparison.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_equalize.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_equalize.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_equalize.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_equalize.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_harmonize.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_harmonize.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_harmonize.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_harmonize.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_interpolation.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_interpolation.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_interpolation.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_interpolation.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mix.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mix.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mix.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mix.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mixins.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mixins.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mixins.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_mixins.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_ryb.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_ryb.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_ryb.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_ryb.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_tint-shade.scss b/netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_tint-shade.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_tint-shade.scss
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/scss/vendor/color-schemer/color-schemer/_tint-shade.scss
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/chapter.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/chapter.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/chapter.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/chapter.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/default.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/default.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/default.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/default.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/docs.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/docs.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/docs.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/docs.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/error.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/error.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/error.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/error.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/analytics.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/analytics.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/analytics.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/analytics.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/base.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/base.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/base.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/base.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/github_link.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/github_link.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/github_link.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/github_link.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/github_note.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/github_note.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/github_note.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/github_note.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/logo.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/logo.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/logo.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/logo.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/metadata.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/metadata.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/metadata.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/metadata.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/page.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/page.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/page.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/page.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/sidebar.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/sidebar.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/templates/partials/sidebar.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/templates/partials/sidebar.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/learn2/thumbnail.jpg b/netbox/project-static/select2-4.0.13/docs/themes/learn2/thumbnail.jpg
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/learn2/thumbnail.jpg
rename to netbox/project-static/select2-4.0.13/docs/themes/learn2/thumbnail.jpg
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/css/s2-docs.css b/netbox/project-static/select2-4.0.13/docs/themes/site/css/s2-docs.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/css/s2-docs.css
rename to netbox/project-static/select2-4.0.13/docs/themes/site/css/s2-docs.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/css/theme.css b/netbox/project-static/select2-4.0.13/docs/themes/site/css/theme.css
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/css/theme.css
rename to netbox/project-static/select2-4.0.13/docs/themes/site/css/theme.css
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-36x36.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-36x36.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-36x36.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-36x36.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-48x48.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-48x48.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-48x48.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-48x48.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-72x72.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-72x72.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/android-chrome-72x72.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/android-chrome-72x72.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-57x57.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-57x57.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-57x57.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-57x57.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-60x60.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-60x60.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-60x60.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-60x60.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-72x72.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-72x72.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-72x72.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-72x72.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-precomposed.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-precomposed.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon-precomposed.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon-precomposed.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/apple-touch-icon.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/apple-touch-icon.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon-16x16.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon-16x16.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon-16x16.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon-16x16.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon-32x32.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon-32x32.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon-32x32.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon-32x32.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon.ico b/netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon.ico
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon.ico
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon.ico
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/favicon.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/favicon.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/manifest.json b/netbox/project-static/select2-4.0.13/docs/themes/site/images/manifest.json
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/manifest.json
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/manifest.json
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-150x150.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-150x150.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-150x150.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-150x150.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-310x150.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-310x150.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-310x150.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-310x150.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-70x70.png b/netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-70x70.png
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/mstile-70x70.png
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/mstile-70x70.png
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/images/safari-pinned-tab.svg b/netbox/project-static/select2-4.0.13/docs/themes/site/images/safari-pinned-tab.svg
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/images/safari-pinned-tab.svg
rename to netbox/project-static/select2-4.0.13/docs/themes/site/images/safari-pinned-tab.svg
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/js/data-fill-from.js b/netbox/project-static/select2-4.0.13/docs/themes/site/js/data-fill-from.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/js/data-fill-from.js
rename to netbox/project-static/select2-4.0.13/docs/themes/site/js/data-fill-from.js
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/site.yaml b/netbox/project-static/select2-4.0.13/docs/themes/site/site.yaml
similarity index 65%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/site.yaml
rename to netbox/project-static/select2-4.0.13/docs/themes/site/site.yaml
index f1ecec179..a486ca025 100644
--- a/netbox/project-static/select2-4.0.12/docs/themes/site/site.yaml
+++ b/netbox/project-static/select2-4.0.13/docs/themes/site/site.yaml
@@ -9,5 +9,5 @@ streams:
google_analytics_code: UA-57144786-2
github:
position: top # top | bottom | off
- tree: https://github.com/select2/docs/blob/develop/
- commits: https://github.com/select2/docs/commits/develop/
+ tree: https://github.com/select2/select2/blob/develop/docs/
+ commits: https://github.com/select2/select2/commits/develop/docs/
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/base.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/base.html.twig
similarity index 98%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/base.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/base.html.twig
index d26baa401..7cde0c918 100644
--- a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/base.html.twig
+++ b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/base.html.twig
@@ -17,7 +17,7 @@
{% do assets.addCss('theme://css/custom.css',100) %}
{% do assets.addCss('theme://css/font-awesome.min.css',100) %}
{% do assets.addCss('theme://css/featherlight.min.css') %}
- {% do assets.addCss('https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/css/select2.min.css') %}
+ {% do assets.addCss('https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/css/select2.min.css') %}
{% do assets.addCss('theme://css/s2-docs.css', 100) %}
{% do assets.addCss('theme://css/theme.css',100) %}
@@ -33,7 +33,7 @@
{% block javascripts %}
{% do assets.addJs('jquery',101) %}
{% do assets.addJs('theme://js/modernizr.custom.71422.js',100) %}
- {% do assets.addJs('https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/js/select2.full.min.js', 100) %}
+ {% do assets.addJs('https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/js/select2.full.min.js', 100) %}
{% do assets.addJs('https://cdn.rawgit.com/google/code-prettify/master/loader/run_prettify.js', 100) %}
{% do assets.addJs('theme://js/featherlight.min.js') %}
{% do assets.addJs('theme://js/clipboard.min.js') %}
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/js/source-states.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/js/source-states.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/js/source-states.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/js/source-states.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/logo.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/logo.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/logo.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/logo.html.twig
diff --git a/netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/sidebar.html.twig b/netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/sidebar.html.twig
similarity index 100%
rename from netbox/project-static/select2-4.0.12/docs/themes/site/templates/partials/sidebar.html.twig
rename to netbox/project-static/select2-4.0.13/docs/themes/site/templates/partials/sidebar.html.twig
diff --git a/netbox/project-static/select2-4.0.12/package.json b/netbox/project-static/select2-4.0.13/package.json
similarity index 98%
rename from netbox/project-static/select2-4.0.12/package.json
rename to netbox/project-static/select2-4.0.13/package.json
index c7c3fb0a3..30135ba94 100644
--- a/netbox/project-static/select2-4.0.12/package.json
+++ b/netbox/project-static/select2-4.0.13/package.json
@@ -39,7 +39,7 @@
"src",
"dist"
],
- "version": "4.0.12",
+ "version": "4.0.13",
"jspm": {
"main": "js/select2",
"directories": {
diff --git a/netbox/project-static/select2-4.0.12/src/js/banner.end.js b/netbox/project-static/select2-4.0.13/src/js/banner.end.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/banner.end.js
rename to netbox/project-static/select2-4.0.13/src/js/banner.end.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/banner.start.js b/netbox/project-static/select2-4.0.13/src/js/banner.start.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/banner.start.js
rename to netbox/project-static/select2-4.0.13/src/js/banner.start.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/jquery.mousewheel.shim.js b/netbox/project-static/select2-4.0.13/src/js/jquery.mousewheel.shim.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/jquery.mousewheel.shim.js
rename to netbox/project-static/select2-4.0.13/src/js/jquery.mousewheel.shim.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/jquery.select2.js b/netbox/project-static/select2-4.0.13/src/js/jquery.select2.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/jquery.select2.js
rename to netbox/project-static/select2-4.0.13/src/js/jquery.select2.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/jquery.shim.js b/netbox/project-static/select2-4.0.13/src/js/jquery.shim.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/jquery.shim.js
rename to netbox/project-static/select2-4.0.13/src/js/jquery.shim.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/containerCss.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/containerCss.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/containerCss.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/containerCss.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/dropdownCss.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/dropdownCss.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/dropdownCss.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/dropdownCss.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/initSelection.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/initSelection.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/initSelection.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/initSelection.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/inputData.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/inputData.js
similarity index 94%
rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/inputData.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/inputData.js
index 6e1dee261..cd58c5c94 100644
--- a/netbox/project-static/select2-4.0.12/src/js/select2/compat/inputData.js
+++ b/netbox/project-static/select2-4.0.13/src/js/select2/compat/inputData.js
@@ -65,13 +65,13 @@ define([
});
this.$element.val(data.id);
- this.$element.trigger('change');
+ this.$element.trigger('input').trigger('change');
} else {
var value = this.$element.val();
value += this._valueSeparator + data.id;
this.$element.val(value);
- this.$element.trigger('change');
+ this.$element.trigger('input').trigger('change');
}
};
@@ -94,7 +94,7 @@ define([
}
self.$element.val(values.join(self._valueSeparator));
- self.$element.trigger('change');
+ self.$element.trigger('input').trigger('change');
});
};
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/matcher.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/matcher.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/matcher.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/matcher.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/query.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/query.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/query.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/query.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/compat/utils.js b/netbox/project-static/select2-4.0.13/src/js/select2/compat/utils.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/compat/utils.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/compat/utils.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/core.js b/netbox/project-static/select2-4.0.13/src/js/select2/core.js
similarity index 91%
rename from netbox/project-static/select2-4.0.12/src/js/select2/core.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/core.js
index 92084143a..831691f81 100644
--- a/netbox/project-static/select2-4.0.12/src/js/select2/core.js
+++ b/netbox/project-static/select2-4.0.13/src/js/select2/core.js
@@ -208,8 +208,8 @@ define([
if (observer != null) {
this._observer = new observer(function (mutations) {
- $.each(mutations, self._syncA);
- $.each(mutations, self._syncS);
+ self._syncA();
+ self._syncS(null, mutations);
});
this._observer.observe(this.$element[0], {
attributes: true,
@@ -331,7 +331,7 @@ define([
if (self.isOpen()) {
if (key === KEYS.ESC || key === KEYS.TAB ||
(key === KEYS.UP && evt.altKey)) {
- self.close();
+ self.close(evt);
evt.preventDefault();
} else if (key === KEYS.ENTER) {
@@ -365,7 +365,7 @@ define([
Select2.prototype._syncAttributes = function () {
this.options.set('disabled', this.$element.prop('disabled'));
- if (this.options.get('disabled')) {
+ if (this.isDisabled()) {
if (this.isOpen()) {
this.close();
}
@@ -376,7 +376,7 @@ define([
}
};
- Select2.prototype._syncSubtree = function (evt, mutations) {
+ Select2.prototype._isChangeMutation = function (evt, mutations) {
var changed = false;
var self = this;
@@ -404,7 +404,22 @@ define([
}
} else if (mutations.removedNodes && mutations.removedNodes.length > 0) {
changed = true;
+ } else if ($.isArray(mutations)) {
+ $.each(mutations, function(evt, mutation) {
+ if (self._isChangeMutation(evt, mutation)) {
+ // We've found a change mutation.
+ // Let's escape from the loop and continue
+ changed = true;
+ return false;
+ }
+ });
}
+ return changed;
+ };
+
+ Select2.prototype._syncSubtree = function (evt, mutations) {
+ var changed = this._isChangeMutation(evt, mutations);
+ var self = this;
// Only re-pull the data if we think there is a change
if (changed) {
@@ -455,7 +470,7 @@ define([
};
Select2.prototype.toggleDropdown = function () {
- if (this.options.get('disabled')) {
+ if (this.isDisabled()) {
return;
}
@@ -471,15 +486,40 @@ define([
return;
}
+ if (this.isDisabled()) {
+ return;
+ }
+
this.trigger('query', {});
};
- Select2.prototype.close = function () {
+ Select2.prototype.close = function (evt) {
if (!this.isOpen()) {
return;
}
- this.trigger('close', {});
+ this.trigger('close', { originalEvent : evt });
+ };
+
+ /**
+ * Helper method to abstract the "enabled" (not "disabled") state of this
+ * object.
+ *
+ * @return {true} if the instance is not disabled.
+ * @return {false} if the instance is disabled.
+ */
+ Select2.prototype.isEnabled = function () {
+ return !this.isDisabled();
+ };
+
+ /**
+ * Helper method to abstract the "disabled" state of this object.
+ *
+ * @return {true} if the disabled option is true.
+ * @return {false} if the disabled option is false.
+ */
+ Select2.prototype.isDisabled = function () {
+ return this.options.get('disabled');
};
Select2.prototype.isOpen = function () {
@@ -556,7 +596,7 @@ define([
});
}
- this.$element.val(newVal).trigger('change');
+ this.$element.val(newVal).trigger('input').trigger('change');
};
Select2.prototype.destroy = function () {
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/ajax.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/ajax.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/ajax.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/ajax.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/array.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/array.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/array.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/array.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/base.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/base.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/base.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/base.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/maximumInputLength.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/maximumInputLength.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/maximumInputLength.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/maximumInputLength.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/maximumSelectionLength.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/maximumSelectionLength.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/maximumSelectionLength.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/maximumSelectionLength.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/minimumInputLength.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/minimumInputLength.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/minimumInputLength.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/minimumInputLength.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/select.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/select.js
similarity index 95%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/select.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/select.js
index a897198b5..a38473502 100644
--- a/netbox/project-static/select2-4.0.12/src/js/select2/data/select.js
+++ b/netbox/project-static/select2-4.0.13/src/js/select2/data/select.js
@@ -36,7 +36,7 @@ define([
if ($(data.element).is('option')) {
data.element.selected = true;
- this.$element.trigger('change');
+ this.$element.trigger('input').trigger('change');
return;
}
@@ -57,13 +57,13 @@ define([
}
self.$element.val(val);
- self.$element.trigger('change');
+ self.$element.trigger('input').trigger('change');
});
} else {
var val = data.id;
this.$element.val(val);
- this.$element.trigger('change');
+ this.$element.trigger('input').trigger('change');
}
};
@@ -79,7 +79,7 @@ define([
if ($(data.element).is('option')) {
data.element.selected = false;
- this.$element.trigger('change');
+ this.$element.trigger('input').trigger('change');
return;
}
@@ -97,7 +97,7 @@ define([
self.$element.val(val);
- self.$element.trigger('change');
+ self.$element.trigger('input').trigger('change');
});
};
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/tags.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/tags.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/tags.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/tags.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/data/tokenizer.js b/netbox/project-static/select2-4.0.13/src/js/select2/data/tokenizer.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/data/tokenizer.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/data/tokenizer.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/defaults.js b/netbox/project-static/select2-4.0.13/src/js/select2/defaults.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/defaults.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/defaults.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/diacritics.js b/netbox/project-static/select2-4.0.13/src/js/select2/diacritics.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/diacritics.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/diacritics.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/attachBody.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/attachBody.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/attachBody.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/attachBody.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/attachContainer.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/attachContainer.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/attachContainer.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/attachContainer.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/closeOnSelect.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/closeOnSelect.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/closeOnSelect.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/closeOnSelect.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/hidePlaceholder.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/hidePlaceholder.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/hidePlaceholder.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/hidePlaceholder.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/infiniteScroll.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/infiniteScroll.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/infiniteScroll.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/infiniteScroll.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/minimumResultsForSearch.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/minimumResultsForSearch.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/minimumResultsForSearch.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/minimumResultsForSearch.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/search.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/search.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/search.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/search.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/selectOnClose.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/selectOnClose.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/selectOnClose.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/selectOnClose.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/dropdown/stopPropagation.js b/netbox/project-static/select2-4.0.13/src/js/select2/dropdown/stopPropagation.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/dropdown/stopPropagation.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/dropdown/stopPropagation.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/af.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/af.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/af.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/af.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ar.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ar.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ar.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ar.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/az.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/az.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/az.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/az.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/bg.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/bg.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/bg.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/bg.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/bn.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/bn.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/bn.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/bn.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/bs.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/bs.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/bs.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/bs.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ca.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ca.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ca.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ca.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/cs.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/cs.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/cs.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/cs.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/da.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/da.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/da.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/da.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/de.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/de.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/de.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/de.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/dsb.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/dsb.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/dsb.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/dsb.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/el.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/el.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/el.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/el.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/en.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/en.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/en.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/en.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/es.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/es.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/es.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/es.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/et.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/et.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/et.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/et.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/eu.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/eu.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/eu.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/eu.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/fa.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/fa.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/fa.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/fa.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/fi.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/fi.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/fi.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/fi.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/fr.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/fr.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/fr.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/fr.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/gl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/gl.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/gl.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/gl.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/he.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/he.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/he.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/he.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hi.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hi.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hi.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hi.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hr.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hr.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hr.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hr.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hsb.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hsb.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hsb.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hsb.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hu.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hu.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hu.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hu.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/hy.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/hy.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/hy.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/hy.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/id.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/id.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/id.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/id.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/is.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/is.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/is.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/is.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/it.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/it.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/it.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/it.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ja.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ja.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ja.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ja.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ka.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ka.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ka.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ka.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/km.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/km.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/km.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/km.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ko.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ko.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ko.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ko.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/lt.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/lt.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/lt.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/lt.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/lv.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/lv.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/lv.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/lv.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/mk.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/mk.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/mk.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/mk.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ms.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ms.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ms.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ms.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/nb.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/nb.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/nb.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/nb.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ne.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ne.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ne.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ne.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/nl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/nl.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/nl.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/nl.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/pl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/pl.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/pl.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/pl.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ps.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ps.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ps.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ps.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/pt-BR.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/pt-BR.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/pt-BR.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/pt-BR.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/pt.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/pt.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/pt.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/pt.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ro.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ro.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ro.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ro.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/ru.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/ru.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/ru.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/ru.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sk.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sk.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sk.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sk.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sl.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sl.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sl.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sq.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sq.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sq.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sq.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sr-Cyrl.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sr-Cyrl.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sr-Cyrl.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sr-Cyrl.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sr.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sr.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sr.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sr.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/sv.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/sv.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/sv.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/sv.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/th.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/th.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/th.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/th.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/tk.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/tk.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/tk.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/tk.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/tr.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/tr.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/tr.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/tr.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/uk.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/uk.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/uk.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/uk.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/vi.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/vi.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/vi.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/vi.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/zh-CN.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/zh-CN.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/zh-CN.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/zh-CN.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/i18n/zh-TW.js b/netbox/project-static/select2-4.0.13/src/js/select2/i18n/zh-TW.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/i18n/zh-TW.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/i18n/zh-TW.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/keys.js b/netbox/project-static/select2-4.0.13/src/js/select2/keys.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/keys.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/keys.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/options.js b/netbox/project-static/select2-4.0.13/src/js/select2/options.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/options.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/options.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/results.js b/netbox/project-static/select2-4.0.13/src/js/select2/results.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/results.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/results.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/allowClear.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/allowClear.js
similarity index 96%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/allowClear.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/allowClear.js
index 0de5b9bbb..7e6a32f10 100644
--- a/netbox/project-static/select2-4.0.12/src/js/select2/selection/allowClear.js
+++ b/netbox/project-static/select2-4.0.13/src/js/select2/selection/allowClear.js
@@ -31,7 +31,7 @@ define([
AllowClear.prototype._handleClear = function (_, evt) {
// Ignore the event if it is disabled
- if (this.options.get('disabled')) {
+ if (this.isDisabled()) {
return;
}
@@ -74,7 +74,7 @@ define([
}
}
- this.$element.trigger('change');
+ this.$element.trigger('input').trigger('change');
this.trigger('toggle', {});
};
@@ -97,7 +97,7 @@ define([
return;
}
- var removeAll = this.options.get('translations').get('removeAllItems');
+ var removeAll = this.options.get('translations').get('removeAllItems');
var $remove = $(
'' +
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/base.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/base.js
similarity index 87%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/base.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/base.js
index f3999b831..ed9c50d32 100644
--- a/netbox/project-static/select2-4.0.12/src/js/select2/selection/base.js
+++ b/netbox/project-static/select2-4.0.13/src/js/select2/selection/base.js
@@ -153,5 +153,26 @@ define([
throw new Error('The `update` method must be defined in child classes.');
};
+ /**
+ * Helper method to abstract the "enabled" (not "disabled") state of this
+ * object.
+ *
+ * @return {true} if the instance is not disabled.
+ * @return {false} if the instance is disabled.
+ */
+ BaseSelection.prototype.isEnabled = function () {
+ return !this.isDisabled();
+ };
+
+ /**
+ * Helper method to abstract the "disabled" state of this object.
+ *
+ * @return {true} if the disabled option is true.
+ * @return {false} if the disabled option is false.
+ */
+ BaseSelection.prototype.isDisabled = function () {
+ return this.options.get('disabled');
+ };
+
return BaseSelection;
});
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/clickMask.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/clickMask.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/clickMask.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/clickMask.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/eventRelay.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/eventRelay.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/eventRelay.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/eventRelay.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/multiple.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/multiple.js
similarity index 98%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/multiple.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/multiple.js
index 17afa4e40..cfd6029c9 100644
--- a/netbox/project-static/select2-4.0.12/src/js/select2/selection/multiple.js
+++ b/netbox/project-static/select2-4.0.13/src/js/select2/selection/multiple.js
@@ -37,7 +37,7 @@ define([
'.select2-selection__choice__remove',
function (evt) {
// Ignore the event if it is disabled
- if (self.options.get('disabled')) {
+ if (self.isDisabled()) {
return;
}
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/placeholder.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/placeholder.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/placeholder.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/placeholder.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/search.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/search.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/search.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/search.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/single.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/single.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/single.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/single.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/selection/stopPropagation.js b/netbox/project-static/select2-4.0.13/src/js/select2/selection/stopPropagation.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/selection/stopPropagation.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/selection/stopPropagation.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/translation.js b/netbox/project-static/select2-4.0.13/src/js/select2/translation.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/translation.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/translation.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/select2/utils.js b/netbox/project-static/select2-4.0.13/src/js/select2/utils.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/select2/utils.js
rename to netbox/project-static/select2-4.0.13/src/js/select2/utils.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/wrapper.end.js b/netbox/project-static/select2-4.0.13/src/js/wrapper.end.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/wrapper.end.js
rename to netbox/project-static/select2-4.0.13/src/js/wrapper.end.js
diff --git a/netbox/project-static/select2-4.0.12/src/js/wrapper.start.js b/netbox/project-static/select2-4.0.13/src/js/wrapper.start.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/js/wrapper.start.js
rename to netbox/project-static/select2-4.0.13/src/js/wrapper.start.js
diff --git a/netbox/project-static/select2-4.0.12/src/scss/_dropdown.scss b/netbox/project-static/select2-4.0.13/src/scss/_dropdown.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/_dropdown.scss
rename to netbox/project-static/select2-4.0.13/src/scss/_dropdown.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/_multiple.scss b/netbox/project-static/select2-4.0.13/src/scss/_multiple.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/_multiple.scss
rename to netbox/project-static/select2-4.0.13/src/scss/_multiple.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/_single.scss b/netbox/project-static/select2-4.0.13/src/scss/_single.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/_single.scss
rename to netbox/project-static/select2-4.0.13/src/scss/_single.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/core.scss b/netbox/project-static/select2-4.0.13/src/scss/core.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/core.scss
rename to netbox/project-static/select2-4.0.13/src/scss/core.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/mixins/_gradients.scss b/netbox/project-static/select2-4.0.13/src/scss/mixins/_gradients.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/mixins/_gradients.scss
rename to netbox/project-static/select2-4.0.13/src/scss/mixins/_gradients.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/classic/_defaults.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/classic/_defaults.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/theme/classic/_defaults.scss
rename to netbox/project-static/select2-4.0.13/src/scss/theme/classic/_defaults.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/classic/_multiple.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/classic/_multiple.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/theme/classic/_multiple.scss
rename to netbox/project-static/select2-4.0.13/src/scss/theme/classic/_multiple.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/classic/_single.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/classic/_single.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/theme/classic/_single.scss
rename to netbox/project-static/select2-4.0.13/src/scss/theme/classic/_single.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/classic/layout.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/classic/layout.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/theme/classic/layout.scss
rename to netbox/project-static/select2-4.0.13/src/scss/theme/classic/layout.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/default/_multiple.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/default/_multiple.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/theme/default/_multiple.scss
rename to netbox/project-static/select2-4.0.13/src/scss/theme/default/_multiple.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/default/_single.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/default/_single.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/theme/default/_single.scss
rename to netbox/project-static/select2-4.0.13/src/scss/theme/default/_single.scss
diff --git a/netbox/project-static/select2-4.0.12/src/scss/theme/default/layout.scss b/netbox/project-static/select2-4.0.13/src/scss/theme/default/layout.scss
similarity index 100%
rename from netbox/project-static/select2-4.0.12/src/scss/theme/default/layout.scss
rename to netbox/project-static/select2-4.0.13/src/scss/theme/default/layout.scss
diff --git a/netbox/project-static/select2-4.0.12/tests/a11y/selection-tests.js b/netbox/project-static/select2-4.0.13/tests/a11y/selection-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/a11y/selection-tests.js
rename to netbox/project-static/select2-4.0.13/tests/a11y/selection-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/data/array-tests.js b/netbox/project-static/select2-4.0.13/tests/data/array-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/data/array-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/array-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/data/base-tests.js b/netbox/project-static/select2-4.0.13/tests/data/base-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/data/base-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/base-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/data/inputData-tests.js b/netbox/project-static/select2-4.0.13/tests/data/inputData-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/data/inputData-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/inputData-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/data/maximumInputLength-tests.js b/netbox/project-static/select2-4.0.13/tests/data/maximumInputLength-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/data/maximumInputLength-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/maximumInputLength-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/data/maximumSelectionLength-tests.js b/netbox/project-static/select2-4.0.13/tests/data/maximumSelectionLength-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/data/maximumSelectionLength-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/maximumSelectionLength-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/data/minimumInputLength-tests.js b/netbox/project-static/select2-4.0.13/tests/data/minimumInputLength-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/data/minimumInputLength-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/minimumInputLength-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/data/select-tests.js b/netbox/project-static/select2-4.0.13/tests/data/select-tests.js
similarity index 90%
rename from netbox/project-static/select2-4.0.12/tests/data/select-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/select-tests.js
index b59c6d4be..544a51e1e 100644
--- a/netbox/project-static/select2-4.0.12/tests/data/select-tests.js
+++ b/netbox/project-static/select2-4.0.13/tests/data/select-tests.js
@@ -167,12 +167,14 @@ test('duplicates - single - same id on select triggers change',
var data = new SelectData($select, data);
var second = $('#qunit-fixture .duplicates option')[2];
- var changeTriggered = false;
+ var changeTriggered = false, inputTriggered = false;
assert.equal($select.val(), 'one');
$select.on('change', function () {
- changeTriggered = true;
+ changeTriggered = inputTriggered;
+ }).on('input', function() {
+ inputTriggered = true;
});
data.select({
@@ -187,9 +189,14 @@ test('duplicates - single - same id on select triggers change',
'The value never changed'
);
+ assert.ok(
+ inputTriggered,
+ 'The input event should be triggered'
+ );
+
assert.ok(
changeTriggered,
- 'The change event should be triggered'
+ 'The change event should be triggered after the input event'
);
assert.ok(
@@ -205,12 +212,14 @@ test('duplicates - single - different id on select triggers change',
var data = new SelectData($select, data);
var second = $('#qunit-fixture .duplicates option')[2];
- var changeTriggered = false;
+ var changeTriggered = false, inputTriggered = false;
$select.val('two');
$select.on('change', function () {
- changeTriggered = true;
+ changeTriggered = inputTriggered;
+ }).on('input', function() {
+ inputTriggered = true;
});
data.select({
@@ -225,9 +234,14 @@ test('duplicates - single - different id on select triggers change',
'The value changed to the duplicate id'
);
+ assert.ok(
+ inputTriggered,
+ 'The input event should be triggered'
+ );
+
assert.ok(
changeTriggered,
- 'The change event should be triggered'
+ 'The change event should be triggered after the input event'
);
assert.ok(
@@ -243,12 +257,14 @@ function (assert) {
var data = new SelectData($select, data);
var second = $('#qunit-fixture .duplicates-multi option')[2];
- var changeTriggered = false;
+ var changeTriggered = false, inputTriggered = false;
$select.val(['one']);
$select.on('change', function () {
- changeTriggered = true;
+ changeTriggered = inputTriggered;
+ }).on('input', function() {
+ inputTriggered = true;
});
data.select({
@@ -263,9 +279,14 @@ function (assert) {
'The value now has duplicates'
);
+ assert.ok(
+ inputTriggered,
+ 'The input event should be triggered'
+ );
+
assert.ok(
changeTriggered,
- 'The change event should be triggered'
+ 'The change event should be triggered after the input event'
);
assert.ok(
@@ -281,12 +302,14 @@ function (assert) {
var data = new SelectData($select, data);
var second = $('#qunit-fixture .duplicates-multi option')[2];
- var changeTriggered = false;
+ var changeTriggered = false, inputTriggered = false;
$select.val(['two']);
$select.on('change', function () {
- changeTriggered = true;
+ changeTriggered = inputTriggered;
+ }).on('input', function() {
+ inputTriggered = true;
});
data.select({
@@ -301,9 +324,14 @@ function (assert) {
'The value has the new id'
);
+ assert.ok(
+ inputTriggered,
+ 'The input event should be triggered'
+ );
+
assert.ok(
changeTriggered,
- 'The change event should be triggered'
+ 'The change event should be triggered after the input event'
);
assert.ok(
diff --git a/netbox/project-static/select2-4.0.12/tests/data/tags-tests.js b/netbox/project-static/select2-4.0.13/tests/data/tags-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/data/tags-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/tags-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/data/tokenizer-tests.js b/netbox/project-static/select2-4.0.13/tests/data/tokenizer-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/data/tokenizer-tests.js
rename to netbox/project-static/select2-4.0.13/tests/data/tokenizer-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/dropdownCss-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/dropdownCss-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/dropdown/dropdownCss-tests.js
rename to netbox/project-static/select2-4.0.13/tests/dropdown/dropdownCss-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/dropdownParent-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/dropdownParent-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/dropdown/dropdownParent-tests.js
rename to netbox/project-static/select2-4.0.13/tests/dropdown/dropdownParent-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/positioning-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/positioning-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/dropdown/positioning-tests.js
rename to netbox/project-static/select2-4.0.13/tests/dropdown/positioning-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/search-a11y-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/search-a11y-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/dropdown/search-a11y-tests.js
rename to netbox/project-static/select2-4.0.13/tests/dropdown/search-a11y-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/selectOnClose-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/selectOnClose-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/dropdown/selectOnClose-tests.js
rename to netbox/project-static/select2-4.0.13/tests/dropdown/selectOnClose-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/dropdown/stopPropagation-tests.js b/netbox/project-static/select2-4.0.13/tests/dropdown/stopPropagation-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/dropdown/stopPropagation-tests.js
rename to netbox/project-static/select2-4.0.13/tests/dropdown/stopPropagation-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/helpers.js b/netbox/project-static/select2-4.0.13/tests/helpers.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/helpers.js
rename to netbox/project-static/select2-4.0.13/tests/helpers.js
diff --git a/netbox/project-static/select2-4.0.12/tests/integration-jq1.html b/netbox/project-static/select2-4.0.13/tests/integration-jq1.html
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/integration-jq1.html
rename to netbox/project-static/select2-4.0.13/tests/integration-jq1.html
diff --git a/netbox/project-static/select2-4.0.12/tests/integration-jq2.html b/netbox/project-static/select2-4.0.13/tests/integration-jq2.html
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/integration-jq2.html
rename to netbox/project-static/select2-4.0.13/tests/integration-jq2.html
diff --git a/netbox/project-static/select2-4.0.12/tests/integration-jq3.html b/netbox/project-static/select2-4.0.13/tests/integration-jq3.html
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/integration-jq3.html
rename to netbox/project-static/select2-4.0.13/tests/integration-jq3.html
diff --git a/netbox/project-static/select2-4.0.12/tests/integration/dom-changes.js b/netbox/project-static/select2-4.0.13/tests/integration/dom-changes.js
similarity index 87%
rename from netbox/project-static/select2-4.0.12/tests/integration/dom-changes.js
rename to netbox/project-static/select2-4.0.13/tests/integration/dom-changes.js
index 65f3fb912..0ce7a4ec5 100644
--- a/netbox/project-static/select2-4.0.12/tests/integration/dom-changes.js
+++ b/netbox/project-static/select2-4.0.13/tests/integration/dom-changes.js
@@ -1,3 +1,4 @@
+/*jshint browser: true */
module('DOM integration');
test('adding a new unselected option changes nothing', function (assert) {
@@ -286,3 +287,46 @@ test('searching tags does not loose focus', function (assert) {
select.selection.trigger('query', {term: 'f'});
select.selection.trigger('query', {term: 'ff'});
});
+
+
+test('adding multiple options calls selection:update once', function (assert) {
+ assert.expect(1);
+
+ var asyncDone = assert.async();
+
+ var $ = require('jquery');
+ var Select2 = require('select2/core');
+
+ var content = '';
+
+ var $select = $(content);
+
+ $('#qunit-fixture').append($select);
+
+ var select = new Select2($select);
+
+ var eventCalls = 0;
+
+ select.on('selection:update', function () {
+ eventCalls++;
+ });
+
+ $select.html(options);
+
+ setTimeout(function () {
+ assert.equal(
+ eventCalls,
+ 1,
+ 'selection:update was called more than once'
+ );
+ asyncDone();
+ }, 0);
+});
diff --git a/netbox/project-static/select2-4.0.12/tests/integration/jquery-calls.js b/netbox/project-static/select2-4.0.13/tests/integration/jquery-calls.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/integration/jquery-calls.js
rename to netbox/project-static/select2-4.0.13/tests/integration/jquery-calls.js
diff --git a/netbox/project-static/select2-4.0.12/tests/integration/select2-methods.js b/netbox/project-static/select2-4.0.13/tests/integration/select2-methods.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/integration/select2-methods.js
rename to netbox/project-static/select2-4.0.13/tests/integration/select2-methods.js
diff --git a/netbox/project-static/select2-4.0.12/tests/options/ajax-tests.js b/netbox/project-static/select2-4.0.13/tests/options/ajax-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/options/ajax-tests.js
rename to netbox/project-static/select2-4.0.13/tests/options/ajax-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/options/data-tests.js b/netbox/project-static/select2-4.0.13/tests/options/data-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/options/data-tests.js
rename to netbox/project-static/select2-4.0.13/tests/options/data-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/options/deprecated-tests.js b/netbox/project-static/select2-4.0.13/tests/options/deprecated-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/options/deprecated-tests.js
rename to netbox/project-static/select2-4.0.13/tests/options/deprecated-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/options/translation-tests.js b/netbox/project-static/select2-4.0.13/tests/options/translation-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/options/translation-tests.js
rename to netbox/project-static/select2-4.0.13/tests/options/translation-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/options/width-tests.js b/netbox/project-static/select2-4.0.13/tests/options/width-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/options/width-tests.js
rename to netbox/project-static/select2-4.0.13/tests/options/width-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/results/a11y-tests.js b/netbox/project-static/select2-4.0.13/tests/results/a11y-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/results/a11y-tests.js
rename to netbox/project-static/select2-4.0.13/tests/results/a11y-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/results/focusing-tests.js b/netbox/project-static/select2-4.0.13/tests/results/focusing-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/results/focusing-tests.js
rename to netbox/project-static/select2-4.0.13/tests/results/focusing-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/results/infiniteScroll-tests.js b/netbox/project-static/select2-4.0.13/tests/results/infiniteScroll-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/results/infiniteScroll-tests.js
rename to netbox/project-static/select2-4.0.13/tests/results/infiniteScroll-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/results/option-tests.js b/netbox/project-static/select2-4.0.13/tests/results/option-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/results/option-tests.js
rename to netbox/project-static/select2-4.0.13/tests/results/option-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/selection/allowClear-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/allowClear-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/selection/allowClear-tests.js
rename to netbox/project-static/select2-4.0.13/tests/selection/allowClear-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/selection/containerCss-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/containerCss-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/selection/containerCss-tests.js
rename to netbox/project-static/select2-4.0.13/tests/selection/containerCss-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/selection/focusing-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/focusing-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/selection/focusing-tests.js
rename to netbox/project-static/select2-4.0.13/tests/selection/focusing-tests.js
diff --git a/netbox/project-static/select2-4.0.12/tests/selection/multiple-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/multiple-tests.js
similarity index 100%
rename from netbox/project-static/select2-4.0.12/tests/selection/multiple-tests.js
rename to netbox/project-static/select2-4.0.13/tests/selection/multiple-tests.js
diff --git a/netbox/project-static/select2-4.0.13/tests/selection/openOnKeyDown-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/openOnKeyDown-tests.js
new file mode 100644
index 000000000..0da838e2c
--- /dev/null
+++ b/netbox/project-static/select2-4.0.13/tests/selection/openOnKeyDown-tests.js
@@ -0,0 +1,188 @@
+module('Selection containers - Open On Key Down');
+
+var KEYS = require('select2/keys');
+var $ = require('jquery');
+
+/**
+ * Build a keydown event with the given key code and extra options.
+ *
+ * @param {Number} keyCode the keyboard code to be used for the 'which'
+ * attribute of the keydown event.
+ * @param {Object} eventProps extra properties to build the keydown event.
+ *
+ * @return {jQuery.Event} a 'keydown' type event.
+ */
+function buildKeyDownEvent (keyCode, eventProps) {
+ return $.Event('keydown', $.extend({}, { which: keyCode }, eventProps));
+}
+
+/**
+ * Wrapper function providing a select2 element with a given enabled/disabled
+ * state that will get a given keydown event triggered on it. Provide an
+ * assertion callback function to test the results of the triggered event.
+ *
+ * @param {Boolean} isEnabled the enabled state of the desired select2
+ * element.
+ * @param {String} testName name for the test.
+ * @param {Number} keyCode used to set the 'which' attribute of the
+ * keydown event.
+ * @param {Object} eventProps attributes to be used to build the keydown
+ * event.
+ * @param {Function} fn assertion callback to perform checks on the
+ * result of triggering the event, receives the
+ * 'assert' variable for the test and the select2
+ * instance behind the built