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 | - +--------+ +--------+ -``` +![Power distribution model](../../media/power_distribution.png) 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. ![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_ui_guest.png) -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: ![NetBox UI as seen by an administrator](../media/installation/netbox_ui_admin.png) 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: + +![Cable path](../../media/models/dcim_cable_trace.png) + +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 ' + + '' + + '' + + '' + ); + $('#qunit-fixture').append($element); + $element.select2({ disabled: !isEnabled }); + + var select2 = $element.data('select2'); + var $selection = select2.$selection; + + assert.notOk(select2.isOpen(), 'The instance should not be open'); + assert.equal(select2.isEnabled(), isEnabled); + + var event = buildKeyDownEvent(keyCode, eventProps); + assert.ok(event.which, 'The event\'s key code (.which) should be set'); + + $selection.trigger(event); + + fn(assert, select2); + }); +} + +/** + * Test the given keydown event on an enabled element. See #testAbled for + * params. + */ +function testEnabled (testName, keyCode, eventProps, fn) { + testAbled(true, testName, keyCode, eventProps, fn); +} + +/** + * Test the given keydown event on a disabled element. See #testAbled for + * params. + */ +function testDisabled (testName, keyCode, eventProps, fn) { + testAbled(false, testName, keyCode, eventProps, fn); +} + +/** + * Assertion function used by the above test* wrappers. Asserts that the given + * select2 instance is open. + * + * @param {Assert} assert + * @param {Select2} select + * @return {null} + */ +function assertOpened (assert, select2) { + assert.ok(select2.isOpen(), 'The element should be open'); +} + +/** + * Assertion function used by the above test* wrappers. Asserts that the given + * select2 instance is not open. + * + * @param {Assert} assert + * @param {Select2} select + * @return {null} + */ +function assertNotOpened (assert, select2) { + assert.notOk(select2.isOpen(), 'The element should not be open'); +} + +/** + * ENTER, SPACE, and ALT+DOWN should all open an enabled select2 element. + */ +testEnabled( + 'enabled element will open on ENTER', + KEYS.ENTER, {}, + assertOpened +); +testEnabled( + 'enabled element will open on SPACE', + KEYS.SPACE, {}, + assertOpened +); +testEnabled( + 'enabled element will open on ALT+DOWN', + KEYS.DOWN, { altKey: true }, + assertOpened +); + +/** + * Some other keys triggered on an enabled select2 element should not open it. + */ +testEnabled( + 'enabled element will not open on UP', + KEYS.UP, {}, + assertNotOpened +); +testEnabled( + 'enabled element will not open on DOWN', + KEYS.UP, {}, + assertNotOpened +); +testEnabled( + 'enabled element will not open on LEFT', + KEYS.UP, {}, + assertNotOpened +); +testEnabled( + 'enabled element will not open on RIGHT', + KEYS.UP, {}, + assertNotOpened +); + +/* + * The keys that will open an enabled select2 element should not open a disabled + * one. + */ +testDisabled( + 'disabled element will not open on ENTER', + KEYS.ENTER, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on SPACE', + KEYS.SPACE, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on ALT+DOWN', + KEYS.DOWN, { altKey: true }, + assertNotOpened +); + +/** + * Other keys should continue to not open a disabled select2 element. + */ +testDisabled( + 'disabled element will not open on UP', + KEYS.UP, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on DOWN', + KEYS.UP, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on LEFT', + KEYS.UP, {}, + assertNotOpened +); +testDisabled( + 'disabled element will not open on RIGHT', + KEYS.UP, {}, + assertNotOpened +); diff --git a/netbox/project-static/select2-4.0.12/tests/selection/placeholder-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/placeholder-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/placeholder-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/placeholder-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/search-a11y-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/search-a11y-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/search-a11y-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/search-a11y-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/search-placeholder-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/search-placeholder-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/search-placeholder-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/search-placeholder-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/search-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/search-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/search-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/search-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/single-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/single-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/single-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/single-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/selection/stopPropagation-tests.js b/netbox/project-static/select2-4.0.13/tests/selection/stopPropagation-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/selection/stopPropagation-tests.js rename to netbox/project-static/select2-4.0.13/tests/selection/stopPropagation-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/unit-jq1.html b/netbox/project-static/select2-4.0.13/tests/unit-jq1.html similarity index 98% rename from netbox/project-static/select2-4.0.12/tests/unit-jq1.html rename to netbox/project-static/select2-4.0.13/tests/unit-jq1.html index b5ec34612..f1dac344f 100644 --- a/netbox/project-static/select2-4.0.12/tests/unit-jq1.html +++ b/netbox/project-static/select2-4.0.13/tests/unit-jq1.html @@ -97,6 +97,7 @@ + diff --git a/netbox/project-static/select2-4.0.12/tests/unit-jq2.html b/netbox/project-static/select2-4.0.13/tests/unit-jq2.html similarity index 98% rename from netbox/project-static/select2-4.0.12/tests/unit-jq2.html rename to netbox/project-static/select2-4.0.13/tests/unit-jq2.html index 7eca50575..9d4a99b15 100644 --- a/netbox/project-static/select2-4.0.12/tests/unit-jq2.html +++ b/netbox/project-static/select2-4.0.13/tests/unit-jq2.html @@ -97,6 +97,7 @@ + diff --git a/netbox/project-static/select2-4.0.12/tests/unit-jq3.html b/netbox/project-static/select2-4.0.13/tests/unit-jq3.html similarity index 98% rename from netbox/project-static/select2-4.0.12/tests/unit-jq3.html rename to netbox/project-static/select2-4.0.13/tests/unit-jq3.html index a34f771c9..9e062678a 100644 --- a/netbox/project-static/select2-4.0.12/tests/unit-jq3.html +++ b/netbox/project-static/select2-4.0.13/tests/unit-jq3.html @@ -97,6 +97,7 @@ + diff --git a/netbox/project-static/select2-4.0.12/tests/utils/data-tests.js b/netbox/project-static/select2-4.0.13/tests/utils/data-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/utils/data-tests.js rename to netbox/project-static/select2-4.0.13/tests/utils/data-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/utils/decorator-tests.js b/netbox/project-static/select2-4.0.13/tests/utils/decorator-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/utils/decorator-tests.js rename to netbox/project-static/select2-4.0.13/tests/utils/decorator-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/utils/escapeMarkup-tests.js b/netbox/project-static/select2-4.0.13/tests/utils/escapeMarkup-tests.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/utils/escapeMarkup-tests.js rename to netbox/project-static/select2-4.0.13/tests/utils/escapeMarkup-tests.js diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/jquery-1.7.2.js b/netbox/project-static/select2-4.0.13/tests/vendor/jquery-1.7.2.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/jquery-1.7.2.js rename to netbox/project-static/select2-4.0.13/tests/vendor/jquery-1.7.2.js diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/jquery-2.2.4.js b/netbox/project-static/select2-4.0.13/tests/vendor/jquery-2.2.4.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/jquery-2.2.4.js rename to netbox/project-static/select2-4.0.13/tests/vendor/jquery-2.2.4.js diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/jquery-3.4.1.js b/netbox/project-static/select2-4.0.13/tests/vendor/jquery-3.4.1.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/jquery-3.4.1.js rename to netbox/project-static/select2-4.0.13/tests/vendor/jquery-3.4.1.js diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/qunit-1.23.1.css b/netbox/project-static/select2-4.0.13/tests/vendor/qunit-1.23.1.css similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/qunit-1.23.1.css rename to netbox/project-static/select2-4.0.13/tests/vendor/qunit-1.23.1.css diff --git a/netbox/project-static/select2-4.0.12/tests/vendor/qunit-1.23.1.js b/netbox/project-static/select2-4.0.13/tests/vendor/qunit-1.23.1.js similarity index 100% rename from netbox/project-static/select2-4.0.12/tests/vendor/qunit-1.23.1.js rename to netbox/project-static/select2-4.0.13/tests/vendor/qunit-1.23.1.js diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py index 94cd1c7fa..ceb6e0426 100644 --- a/netbox/secrets/admin.py +++ b/netbox/secrets/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin, messages +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.shortcuts import redirect, render from .forms import ActivateUserKeyForm @@ -23,7 +24,7 @@ class UserKeyAdmin(admin.ModelAdmin): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] - if not request.user.has_perm('secrets.activate_userkey'): + if not request.user.has_perm('secrets.change_userkey'): del actions['activate_selected'] return actions @@ -50,7 +51,9 @@ class UserKeyAdmin(admin.ModelAdmin): request, "Invalid private key provided. Unable to retrieve master key.", extra_tags='error' ) else: - form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)}) + form = ActivateUserKeyForm( + initial={'_selected_action': request.POST.getlist(ACTION_CHECKBOX_NAME)} + ) return render(request, 'activate_keys.html', { 'form': form, diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 0b73f0002..2862259d8 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.nested_serializers import NestedDeviceSerializer from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from secrets.models import Secret, SecretRole from utilities.api import ValidatedModelSerializer from .nested_serializers import * @@ -13,23 +13,25 @@ from .nested_serializers import * # class SecretRoleSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') secret_count = serializers.IntegerField(read_only=True) class Meta: model = SecretRole - fields = ['id', 'name', 'slug', 'description', 'secret_count'] + fields = ['id', 'url', 'name', 'slug', 'description', 'secret_count'] -class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): +class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail') device = NestedDeviceSerializer() role = NestedSecretRoleSerializer() plaintext = serializers.CharField() - tags = TagListSerializerField(required=False) class Meta: model = Secret fields = [ - 'id', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'device', 'role', 'name', 'plaintext', 'hash', 'tags', 'custom_fields', 'created', + 'last_updated', ] validators = [] diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py index 7ae2ae9ac..5ad05b09e 100644 --- a/netbox/secrets/api/urls.py +++ b/netbox/secrets/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class SecretsRootView(routers.APIRootView): - """ - Secrets API root view - """ - def get_view_name(self): - return 'Secrets' - - -router = routers.DefaultRouter() -router.APIRootView = SecretsRootView +router = OrderedDefaultRouter() +router.APIRootView = views.SecretsRootView # Secrets router.register('secret-roles', views.SecretRoleViewSet) diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 1795e6c0a..7db6f92b6 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -6,6 +6,7 @@ from django.http import HttpResponseBadRequest from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet from secrets import filters @@ -20,6 +21,14 @@ ERR_PRIVKEY_MISSING = "Private key was not provided." ERR_PRIVKEY_INVALID = "Invalid private key." +class SecretsRootView(APIRootView): + """ + Secrets API root view + """ + def get_view_name(self): + return 'Secrets' + + # # Secret Roles # @@ -27,9 +36,8 @@ ERR_PRIVKEY_INVALID = "Invalid private key." class SecretRoleViewSet(ModelViewSet): queryset = SecretRole.objects.annotate( secret_count=Count('secrets') - ) + ).order_by(*SecretRole._meta.ordering) serializer_class = serializers.SecretRoleSerializer - permission_classes = [IsAuthenticated] filterset_class = filters.SecretRoleFilterSet @@ -39,7 +47,7 @@ class SecretRoleViewSet(ModelViewSet): class SecretViewSet(ModelViewSet): queryset = Secret.objects.prefetch_related( - 'device__primary_ip4', 'device__primary_ip6', 'role', 'role__users', 'role__groups', 'tags', + 'device__primary_ip4', 'device__primary_ip6', 'role', 'tags', ) serializer_class = serializers.SecretSerializer filterset_class = filters.SecretFilterSet @@ -85,8 +93,8 @@ class SecretViewSet(ModelViewSet): secret = self.get_object() - # Attempt to decrypt the secret if the user is permitted and the master key is known - if secret.decryptable_by(request.user) and self.master_key is not None: + # Attempt to decrypt the secret if the master key is known + if self.master_key is not None: secret.decrypt(self.master_key) serializer = self.get_serializer(secret) @@ -103,9 +111,7 @@ class SecretViewSet(ModelViewSet): if self.master_key is not None: secrets = [] for secret in page: - # Enforce role permissions - if secret.decryptable_by(request.user): - secret.decrypt(self.master_key) + secret.decrypt(self.master_key) secrets.append(secret) serializer = self.get_serializer(secrets, many=True) else: diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py deleted file mode 100644 index e2f44ac90..000000000 --- a/netbox/secrets/decorators.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.contrib import messages -from django.shortcuts import redirect - -from .models import UserKey - - -def userkey_required(): - """ - Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of - Secrets). - """ - def _decorator(view): - def wrapped_view(request, *args, **kwargs): - try: - uk = UserKey.objects.get(user=request.user) - except UserKey.DoesNotExist: - messages.warning(request, "This operation requires an active user key, but you don't have one.") - return redirect('user:userkey') - if not uk.is_active(): - messages.warning(request, "This operation is not available. Your user key has not been activated.") - return redirect('user:userkey') - return view(request, *args, **kwargs) - return wrapped_view - return _decorator diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index b4fe4eaff..8f04edc5b 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -5,11 +5,11 @@ from django import forms from dcim.models import Device from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from utilities.forms import ( - APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, + BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + SlugField, TagFilterField, ) from .constants import * from .models import Secret, SecretRole, UserKey @@ -46,13 +46,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): class Meta: model = SecretRole - fields = [ - 'name', 'slug', 'description', 'users', 'groups', - ] - widgets = { - 'users': StaticSelect2Multiple(), - 'groups': StaticSelect2Multiple(), - } + fields = ('name', 'slug', 'description') class SecretRoleCSVForm(CSVModelForm): @@ -69,7 +63,8 @@ class SecretRoleCSVForm(CSVModelForm): class SecretForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( - queryset=Device.objects.all() + queryset=Device.objects.all(), + display_field='display_name' ) plaintext = forms.CharField( max_length=SECRET_PLAINTEXT_MAX_LENGTH, @@ -90,7 +85,8 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): role = DynamicModelChoiceField( queryset=SecretRole.objects.all() ) - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -183,10 +179,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): role = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), to_field_name='slug', - required=False, - widget=APISelectMultiple( - value_field="slug", - ) + required=False ) tag = TagFilterField(model) diff --git a/netbox/secrets/migrations/0001_initial.py b/netbox/secrets/migrations/0001_initial.py index 1281a266a..3664bae63 100644 --- a/netbox/secrets/migrations/0001_initial.py +++ b/netbox/secrets/migrations/0001_initial.py @@ -56,7 +56,6 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['user__username'], - 'permissions': (('activate_userkey', 'Can activate user keys for decryption'),), }, ), migrations.AddField( diff --git a/netbox/secrets/migrations/0009_secretrole_drop_users_groups.py b/netbox/secrets/migrations/0009_secretrole_drop_users_groups.py new file mode 100644 index 000000000..e4110b505 --- /dev/null +++ b/netbox/secrets/migrations/0009_secretrole_drop_users_groups.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0008_standardize_description'), + ('users', '0009_replicate_permissions'), + ] + + operations = [ + migrations.RemoveField( + model_name='secretrole', + name='groups', + ), + migrations.RemoveField( + model_name='secretrole', + name='users', + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 830e91096..6209b5700 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -1,5 +1,4 @@ import os -import sys from Crypto.Cipher import AES from Crypto.PublicKey import RSA @@ -15,9 +14,9 @@ from django.utils.encoding import force_bytes from taggit.managers import TaggableManager from dcim.models import Device -from extras.models import CustomFieldModel, TaggedItem +from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features -from utilities.models import ChangeLoggedModel +from utilities.querysets import RestrictedQuerySet from .exceptions import InvalidKey from .hashers import SecretValidationHasher from .querysets import UserKeyQuerySet @@ -64,9 +63,6 @@ class UserKey(models.Model): class Meta: ordering = ['user__username'] - permissions = ( - ('activate_userkey', "Can activate user keys for decryption"), - ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -243,9 +239,6 @@ class SecretRole(ChangeLoggedModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles such as "Login Credentials" or "SNMP Communities." - - By default, only superusers will have access to decrypt Secrets. To allow other users to decrypt Secrets, grant them - access to the appropriate SecretRoles either individually or by group. """ name = models.CharField( max_length=50, @@ -258,16 +251,8 @@ class SecretRole(ChangeLoggedModel): max_length=200, blank=True, ) - users = models.ManyToManyField( - to=User, - related_name='secretroles', - blank=True - ) - groups = models.ManyToManyField( - to=Group, - related_name='secretroles', - blank=True - ) + + objects = RestrictedQuerySet.as_manager() csv_headers = ['name', 'slug', 'description'] @@ -287,14 +272,6 @@ class SecretRole(ChangeLoggedModel): self.description, ) - def has_member(self, user): - """ - Check whether the given user has belongs to this SecretRole. Note that superusers belong to all roles. - """ - if user.is_superuser: - return True - return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists() - @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Secret(ChangeLoggedModel, CustomFieldModel): @@ -334,9 +311,10 @@ class Secret(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + plaintext = None csv_headers = ['device', 'role', 'name', 'plaintext'] @@ -454,9 +432,3 @@ class Secret(ChangeLoggedModel, CustomFieldModel): if not self.hash: raise Exception("Hash has not been generated for this secret.") return check_password(plaintext, self.hash, preferred=SecretValidationHasher()) - - def decryptable_by(self, user): - """ - Check whether the given user has permission to decrypt this Secret. - """ - return self.role.has_member(user) diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index f92c9216b..5e8c5a8b4 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -1,17 +1,8 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from .models import SecretRole, Secret -SECRETROLE_ACTIONS = """ - - - -{% if perms.secrets.change_secretrole %} - -{% endif %} -""" - # # Secret roles @@ -23,15 +14,11 @@ class SecretRoleTable(BaseTable): secret_count = tables.Column( verbose_name='Secrets' ) - actions = tables.TemplateColumn( - template_code=SECRETROLE_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(SecretRole, pk_field='slug') class Meta(BaseTable.Meta): model = SecretRole - fields = ('pk', 'name', 'secret_count', 'description', 'slug', 'users', 'groups', 'actions') + fields = ('pk', 'name', 'secret_count', 'description', 'slug', 'actions') default_columns = ('pk', 'name', 'secret_count', 'description', 'actions') diff --git a/netbox/secrets/templatetags/__init__.py b/netbox/secrets/templatetags/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/netbox/secrets/templatetags/secret_helpers.py b/netbox/secrets/templatetags/secret_helpers.py deleted file mode 100644 index 142c0d2cb..000000000 --- a/netbox/secrets/templatetags/secret_helpers.py +++ /dev/null @@ -1,12 +0,0 @@ -from django import template - - -register = template.Library() - - -@register.filter() -def decryptable_by(secret, user): - """ - Determine whether a given User is permitted to decrypt a Secret. - """ - return secret.decryptable_by(user) diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 136912773..89c18b7d7 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -5,8 +5,7 @@ from rest_framework import status from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole, SessionKey, UserKey -from users.models import Token -from utilities.testing import APITestCase, APIViewTestCases, create_test_user +from utilities.testing import APITestCase, APIViewTestCases from .constants import PRIVATE_KEY, PUBLIC_KEY @@ -114,3 +113,47 @@ class SecretTest(APIViewTestCases.APIViewTestCase): # Unlock the plaintext prior to evaluation of the instance instance.decrypt(self.master_key) return instance + + +class GetSessionKeyTest(APITestCase): + + def setUp(self): + + super().setUp() + + userkey = UserKey(user=self.user, public_key=PUBLIC_KEY) + userkey.save() + master_key = userkey.get_master_key(PRIVATE_KEY) + self.session_key = SessionKey(userkey=userkey) + self.session_key.save(master_key) + + self.header = { + 'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key), + } + + def test_get_session_key(self): + + encoded_session_key = base64.b64encode(self.session_key.key).decode() + + url = reverse('secrets-api:get-session-key-list') + data = { + 'private_key': PRIVATE_KEY, + } + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertIsNotNone(response.data.get('session_key')) + self.assertNotEqual(response.data.get('session_key'), encoded_session_key) + + def test_get_session_key_preserved(self): + + encoded_session_key = base64.b64encode(self.session_key.key).decode() + + url = reverse('secrets-api:get-session-key-list') + '?preserve_key=True' + data = { + 'private_key': PRIVATE_KEY, + } + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data.get('session_key'), encoded_session_key) diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index 96439a10d..3b7519b7b 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -1,5 +1,6 @@ import base64 +from django.test import override_settings from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site @@ -24,8 +25,6 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Secret Role X', 'slug': 'secret-role-x', 'description': 'A secret role', - 'users': [], - 'groups': [], } cls.csv_data = ( @@ -36,15 +35,17 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ) -class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): +# TODO: Change base class to PrimaryObjectViewTestCase +class SecretTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): model = Secret - # Disable inapplicable tests - test_create_object = None - - # TODO: Check permissions enforcement on secrets.views.secret_edit - test_edit_object = None - @classmethod def setUpTestData(cls): @@ -95,6 +96,7 @@ class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.session_key = SessionKey(userkey=userkey) self.session_key.save(master_key) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_import_objects(self): self.add_permissions('secrets.add_secret') diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index a19ec6ae0..9dbb5d044 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -9,20 +9,21 @@ urlpatterns = [ # Secret roles path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), - path('secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'), path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets path('secrets/', views.SecretListView.as_view(), name='secret_list'), - path('secrets/add/', views.secret_add, name='secret_add'), + path('secrets/add/', views.SecretEditView.as_view(), name='secret_add'), path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), path('secrets//', views.SecretView.as_view(), name='secret'), - path('secrets//edit/', views.secret_edit, name='secret_edit'), + path('secrets//edit/', views.SecretEditView.as_view(), name='secret_edit'), path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index ed59f4392..2872616b8 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,19 +1,17 @@ import base64 +import logging from django.contrib import messages -from django.contrib.auth.decorators import permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.views.generic import View +from django.utils.html import escape +from django.utils.safestring import mark_safe from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .decorators import userkey_required -from .models import SecretRole, Secret, SessionKey +from .models import SecretRole, Secret, SessionKey, UserKey def get_session_key(request): @@ -30,43 +28,36 @@ def get_session_key(request): # Secret roles # -class SecretRoleListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'secrets.view_secretrole' - queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) +class SecretRoleListView(ObjectListView): + queryset = SecretRole.objects.annotate(secret_count=Count('secrets')).order_by(*SecretRole._meta.ordering) table = tables.SecretRoleTable -class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'secrets.add_secretrole' - model = SecretRole +class SecretRoleEditView(ObjectEditView): + queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm - default_return_url = 'secrets:secretrole_list' -class SecretRoleEditView(SecretRoleCreateView): - permission_required = 'secrets.change_secretrole' +class SecretRoleDeleteView(ObjectDeleteView): + queryset = SecretRole.objects.all() -class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'secrets.add_secretrole' +class SecretRoleBulkImportView(BulkImportView): + queryset = SecretRole.objects.all() model_form = forms.SecretRoleCSVForm table = tables.SecretRoleTable - default_return_url = 'secrets:secretrole_list' -class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'secrets.delete_secretrole' - queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) +class SecretRoleBulkDeleteView(BulkDeleteView): + queryset = SecretRole.objects.annotate(secret_count=Count('secrets')).order_by(*SecretRole._meta.ordering) table = tables.SecretRoleTable - default_return_url = 'secrets:secretrole_list' # # Secrets # -class SecretListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'secrets.view_secret' +class SecretListView(ObjectListView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet filterset_form = forms.SecretFilterForm @@ -74,133 +65,96 @@ class SecretListView(PermissionRequiredMixin, ObjectListView): action_buttons = ('import', 'export') -class SecretView(PermissionRequiredMixin, View): - permission_required = 'secrets.view_secret' +class SecretView(ObjectView): + queryset = Secret.objects.all() def get(self, request, pk): - secret = get_object_or_404(Secret, pk=pk) + secret = get_object_or_404(self.queryset, pk=pk) return render(request, 'secrets/secret.html', { 'secret': secret, }) -@permission_required('secrets.add_secret') -@userkey_required() -def secret_add(request): +class SecretEditView(ObjectEditView): + queryset = Secret.objects.all() + model_form = forms.SecretForm + template_name = 'secrets/secret_edit.html' - secret = Secret() - session_key = get_session_key(request) + def dispatch(self, request, *args, **kwargs): + + # Check that the user has a valid UserKey + try: + uk = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + messages.warning(request, "This operation requires an active user key, but you don't have one.") + return redirect('user:userkey') + if not uk.is_active(): + messages.warning(request, "This operation is not available. Your user key has not been activated.") + return redirect('user:userkey') + + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + logger = logging.getLogger('netbox.views.ObjectEditView') + session_key = get_session_key(request) + secret = self.get_object(kwargs) + form = self.model_form(request.POST, instance=secret) - if request.method == 'POST': - form = forms.SecretForm(request.POST, instance=secret) if form.is_valid(): + logger.debug("Form validation was successful") - # We need a valid session key in order to create a Secret - if session_key is None: + # We must have a session key in order to create a secret or update the plaintext of an existing secret + if (form.cleaned_data['plaintext'] or secret.pk is None) and session_key is None: + logger.debug("Unable to proceed: No session key was provided with the request") form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") - # Create and encrypt the new Secret else: master_key = None try: sk = SessionKey.objects.get(userkey__user=request.user) master_key = sk.get_master_key(session_key) except SessionKey.DoesNotExist: + logger.debug("Unable to proceed: User has no session key assigned") form.add_error(None, "No session key found for this user.") if master_key is not None: + logger.debug("Successfully resolved master key for encryption") secret = form.save(commit=False) - secret.plaintext = str(form.cleaned_data['plaintext']) + if form.cleaned_data['plaintext']: + secret.plaintext = str(form.cleaned_data['plaintext']) secret.encrypt(master_key) secret.save() form.save_m2m() - messages.success(request, "Added new secret: {}.".format(secret)) - if '_addanother' in request.POST: - return redirect('secrets:secret_add') - else: - return redirect('secrets:secret', pk=secret.pk) + msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified') + logger.info(f"{msg} {secret} (PK: {secret.pk})") + msg = '{} {}'.format(msg, secret.get_absolute_url(), escape(secret)) + messages.success(request, mark_safe(msg)) - else: - initial_data = { - 'device': request.GET.get('device'), - } - form = forms.SecretForm(initial=initial_data) + return redirect(self.get_return_url(request, secret)) - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': GetReturnURLMixin().get_return_url(request, secret) - }) + else: + logger.debug("Form validation failed") + + return render(request, self.template_name, { + 'obj': secret, + 'obj_type': self.queryset.model._meta.verbose_name, + 'form': form, + 'return_url': self.get_return_url(request, secret), + }) -@permission_required('secrets.change_secret') -@userkey_required() -def secret_edit(request, pk): - - secret = get_object_or_404(Secret, pk=pk) - session_key = get_session_key(request) - - if request.method == 'POST': - form = forms.SecretForm(request.POST, instance=secret) - if form.is_valid(): - - # Re-encrypt the Secret if a plaintext and session key have been provided. - if form.cleaned_data['plaintext'] and session_key is not None: - - # Retrieve the master key using the provided session key - master_key = None - try: - sk = SessionKey.objects.get(userkey__user=request.user) - master_key = sk.get_master_key(session_key) - except SessionKey.DoesNotExist: - form.add_error(None, "No session key found for this user.") - - # Create and encrypt the new Secret - if master_key is not None: - secret = form.save(commit=False) - secret.plaintext = form.cleaned_data['plaintext'] - secret.encrypt(master_key) - secret.save() - messages.success(request, "Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) - else: - form.add_error(None, "Invalid session key. Unable to encrypt secret data.") - - # We can't save the plaintext without a session key. - elif form.cleaned_data['plaintext']: - form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.") - - # If no new plaintext was specified, a session key is not needed. - else: - secret = form.save() - messages.success(request, "Modified secret {}.".format(secret)) - return redirect('secrets:secret', pk=secret.pk) - - else: - form = forms.SecretForm(instance=secret) - - return render(request, 'secrets/secret_edit.html', { - 'secret': secret, - 'form': form, - 'return_url': reverse('secrets:secret', kwargs={'pk': secret.pk}), - }) - - -class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'secrets.delete_secret' - model = Secret - default_return_url = 'secrets:secret_list' +class SecretDeleteView(ObjectDeleteView): + queryset = Secret.objects.all() class SecretBulkImportView(BulkImportView): - permission_required = 'secrets.add_secret' + queryset = Secret.objects.all() model_form = forms.SecretCSVForm table = tables.SecretTable template_name = 'secrets/secret_import.html' - default_return_url = 'secrets:secret_list' widget_attrs = {'class': 'requires-session-key'} master_key = None @@ -243,18 +197,14 @@ class SecretBulkImportView(BulkImportView): }) -class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'secrets.change_secret' +class SecretBulkEditView(BulkEditView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet table = tables.SecretTable form = forms.SecretBulkEditForm - default_return_url = 'secrets:secret_list' -class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'secrets.delete_secret' +class SecretBulkDeleteView(BulkDeleteView): queryset = Secret.objects.prefetch_related('role', 'device') filterset = filters.SecretFilterSet table = tables.SecretTable - default_return_url = 'secrets:secret_list' diff --git a/netbox/templates/403.html b/netbox/templates/403.html new file mode 100644 index 000000000..6a114301f --- /dev/null +++ b/netbox/templates/403.html @@ -0,0 +1,9 @@ +{% extends '40x.html' %} + +{% block title %}Access Denied{% endblock %} + +{% block icon %}{% endblock %} + +{% block message %} + You do not have permission to access this page. +{% endblock %} diff --git a/netbox/templates/404.html b/netbox/templates/404.html index f2fe6b430..22c17fed4 100644 --- a/netbox/templates/404.html +++ b/netbox/templates/404.html @@ -1,19 +1,9 @@ -{% extends 'base.html' %} +{% extends '40x.html' %} -{% block content %} -
-
-
-
- Page Not Found -
-
- The requested page does not exist. -
- -
-
-
+{% block title %}Page Not Found{% endblock %} + +{% block icon %}{% endblock %} + +{% block message %} + The requested page does not exist. {% endblock %} diff --git a/netbox/templates/40x.html b/netbox/templates/40x.html new file mode 100644 index 000000000..d923d001e --- /dev/null +++ b/netbox/templates/40x.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+
+ {% block icon %}{% endblock %} {% block title %}{% endblock %} +
+
+ {% block message %}{% endblock %} +
+ +
+
+
+{% endblock %} diff --git a/netbox/templates/base.html b/netbox/templates/base.html index 24c29a780..20042d151 100644 --- a/netbox/templates/base.html +++ b/netbox/templates/base.html @@ -14,8 +14,8 @@ href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}" onerror="window.location='{% url 'media_failure' %}?filename=jquery-ui-1.12.1/jquery-ui.css'"> + href="{% static 'select2-4.0.13/dist/css/select2.min.css' %}" + onerror="window.location='{% url 'media_failure' %}?filename=select2-4.0.13/dist/css/select2.min.css'"> @@ -86,10 +86,10 @@ onerror="window.location='{% url 'media_failure' %}?filename=jquery-ui-1.12.1/jquery-ui.min.js'"> - - + + -{% endblock %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html new file mode 100644 index 000000000..3f4d7b306 --- /dev/null +++ b/netbox/templates/dcim/consoleport.html @@ -0,0 +1,115 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
+ Console Port +
+ + + + + + + + + + + + + + + + + + + + + +
Device + {{ instance.device }} +
Name{{ instance.name }}
Label{{ instance.label|placeholder }}
Type{{ instance.get_type_display }}
Description{{ instance.description|placeholder }}
+
+ {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
+
+
+
+ Connection +
+ {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
Device + {{ instance.connected_endpoint.device }} +
Name + {{ instance.connected_endpoint.name }} +
Type{{ instance.connected_endpoint.get_type_display|placeholder }}
Description{{ instance.connected_endpoint.description|placeholder }}
Cable + {{ instance.cable }} + + + +
Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
+ {% else %} +
+ Not connected + {% if perms.dcim.add_cable %} + + + + + {% endif %} +
+ {% endif %} +
+ {% plugin_right_page instance %} +
+
+
+
+ {% plugin_full_width_page instance %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html new file mode 100644 index 000000000..77d17fe8a --- /dev/null +++ b/netbox/templates/dcim/consoleserverport.html @@ -0,0 +1,115 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
+ Console Server Port +
+ + + + + + + + + + + + + + + + + + + + + +
Device + {{ instance.device }} +
Name{{ instance.name }}
Label{{ instance.label|placeholder }}
Type{{ instance.get_type_display }}
Description{{ instance.description|placeholder }}
+
+ {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
+
+
+
+ Connection +
+ {% if instance.cable %} + + {% if instance.connected_endpoint %} + + + + + + + + + + + + + + + + + {% endif %} + + + + + + + + +
Device + {{ instance.connected_endpoint.device }} +
Name + {{ instance.connected_endpoint.name }} +
Type{{ instance.connected_endpoint.get_type_display|placeholder }}
Description{{ instance.connected_endpoint.description|placeholder }}
Cable + {{ instance.cable }} + + + +
Connection Status + {% if instance.connection_status %} + {{ instance.get_connection_status_display }} + {% else %} + {{ instance.get_connection_status_display }} + {% endif %} +
+ {% else %} +
+ Not connected + {% if perms.dcim.add_cable %} + + + + + {% endif %} +
+ {% endif %} +
+ {% plugin_right_page instance %} +
+
+
+
+ {% plugin_full_width_page instance %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index ebeda4b80..e97893c30 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -74,6 +74,9 @@ {% if perms.dcim.add_devicebay %}
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitem %} +
  • Inventory Items
  • + {% endif %} {% endif %} @@ -98,10 +101,10 @@ - {% if perms.dcim.napalm_read %} + {% if perms.dcim.napalm_read_device %} {% if device.status != 'active' %} {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %} {% elif not device.platform %} @@ -324,35 +327,6 @@ {% plugin_left_page device %}
    - {% if console_ports or power_ports %} -
    -
    - Console / Power -
    - - {% for cp in console_ports %} - {% include 'dcim/inc/consoleport.html' %} - {% endfor %} - {% for pp in power_ports %} - {% include 'dcim/inc/powerport.html' %} - {% endfor %} -
    - {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} - - {% endif %} -
    - {% endif %} {% if power_ports and poweroutlets %}
    @@ -498,376 +472,490 @@
    - {% if device_bays or device.device_type.is_parent_device %} - {% if perms.dcim.delete_devicebay %} + +
    +
    - {% csrf_token %} - {% endif %} -
    -
    - Device Bays -
    - - - - {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} - - - - - - - - - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} - - - - {% endfor %} - -
    NameStatusDescriptionInstalled Device
    — No device bays defined —
    - -
    - {% if perms.dcim.delete_devicebay %} -
    - {% endif %} - {% endif %} - {% if interfaces %} - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} -
    - {% csrf_token %} - - {% endif %} -
    -
    - Interfaces -
    - -
    -
    - -
    -
    - - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% endfor %} - -
    NameLAGDescriptionMTUModeCableConnection
    - -
    - {% if perms.dcim.delete_interface %} -
    - {% endif %} - {% endif %} - {% if consoleserverports %} - {% if perms.dcim.delete_consoleserverport %} -
    - {% csrf_token %} - - {% endif %} -
    -
    - Console Server Ports -
    - - - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - - - - - - {% for csp in consoleserverports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% endfor %} - -
    NameTypeDescriptionCableConnection
    - -
    - {% if perms.dcim.delete_consoleserverport %} -
    - {% endif %} - {% endif %} - {% if poweroutlets %} - {% if perms.dcim.delete_poweroutlet %} -
    - {% csrf_token %} - - {% endif %} -
    -
    - Power Outlets -
    - - - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - - - - - - - {% for po in poweroutlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% endfor %} - -
    NameTypeInput/LegDescriptionCableConnection
    - -
    - {% if perms.dcim.delete_poweroutlet %} -
    - {% endif %} - {% endif %} - {% if front_ports %} -
    - {% csrf_token %} - -
    -
    - Front Ports -
    - - - - {% if perms.dcim.change_frontport or perms.dcim.delete_frontport %} - - {% endif %} - - - - - - - - - - - - {% for frontport in front_ports %} - {% include 'dcim/inc/frontport.html' %} - {% endfor %} - -
    NameTypeRear PortPositionDescriptionCableConnection
    -
    {% include 'inc/modal.html' with name='graphs' title='Graphs' %} @@ -887,7 +975,7 @@ function toggleConnection(elem) { xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}"); }, data: { - 'status': 'False' + 'status': 'planned' }, context: this, success: function() { @@ -906,7 +994,7 @@ function toggleConnection(elem) { xhr.setRequestHeader("X-CSRFToken", "{{ csrf_token }}"); }, data: { - 'status': 'True' + 'status': 'connected' }, context: this, success: function() { diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html new file mode 100644 index 000000000..9fa66502e --- /dev/null +++ b/netbox/templates/dcim/device_component.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} +{% load helpers %} +{% load perms %} +{% load plugins %} + +{% block header %} + +
    + {% plugin_buttons instance %} + {% if request.user|can_change:instance %} + + Edit + + {% endif %} + {% if request.user|can_delete:instance %} + + Delete + + {% endif %} +
    +

    {% block title %}{{ instance.device }} / {{ instance }}{% endblock %}

    + +{% endblock %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 1486c1ad5..0f1ac2886 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -21,6 +21,7 @@
    Location
    + {% render_field form.region %} {% render_field form.site %} {% render_field form.rack %} {% if obj.device_type.is_child_device and obj.parent_bay %} diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 1a7e5d793..69afbb6a1 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -5,61 +5,61 @@ {% block content %}
    -
    -
    -
    - Chassis -
    - - - - - - - - - - - - - -
    Model{{ device.device_type.display_name }}
    Serial Number{{ device.serial|placeholder }}
    Asset Tag{{ device.asset_tag|placeholder }}
    -
    -
    -
    -
    -
    - Hardware -
    - - - - - - - - - - - - - - - {% for item in inventory_items %} - {% with template_name='dcim/inc/inventoryitem.html' indent=0 %} - {% include template_name %} - {% endwith %} - {% endfor %} - -
    NameManufacturerPart IDSerial NumberAsset TagDescription
    - {% if perms.dcim.add_inventoryitem %} -
    {% endblock %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index ebee21d18..b1cd32eea 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -14,12 +14,8 @@ {% if perms.dcim.add_interface %}
  • Interfaces
  • {% endif %} {% if perms.dcim.add_rearport %}
  • Rear Ports
  • {% endif %} {% if perms.dcim.add_devicebay %}
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitem %}
  • Inventory Items
  • {% endif %}
    {% endif %} - {% if perms.dcim.add_virtualchassis %} - - {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html new file mode 100644 index 000000000..3d65e8bde --- /dev/null +++ b/netbox/templates/dcim/devicebay.html @@ -0,0 +1,70 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Device Bay +
    + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    +
    +
    + Installed Device +
    + {% if instance.installed_device %} + {% with device=instance.installed_device %} + + + + + + + + + +
    Device + {{ device }} +
    Device Type{{ device.device_type }}
    + {% endwith %} + {% else %} +
    + None +
    + {% endif %} +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 2479d58d2..a9addbee2 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -63,149 +63,157 @@ {% endblock %} {% block content %} -
    -
    -
    -
    - Chassis +
    +
    +
    +
    + Chassis +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Manufacturer{{ devicetype.manufacturer }}
    Model Name + {{ devicetype.model }}
    + {{ devicetype.slug }} +
    Part Number{{ devicetype.part_number|placeholder }}
    Height (U){{ devicetype.u_height }}
    Full Depth + {% if devicetype.is_full_depth %} + + {% else %} + + {% endif %} +
    Parent/Child + {{ devicetype.get_subdevice_role_display|placeholder }} +
    Front Image + {% if devicetype.front_image %} + + {{ devicetype.front_image.name }} + + {% else %} + + {% endif %} +
    Rear Image + {% if devicetype.rear_image %} + + {{ devicetype.rear_image.name }} + + {% else %} + + {% endif %} +
    Instances{{ instance_count }}
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Manufacturer{{ devicetype.manufacturer }}
    Model Name - {{ devicetype.model }}
    - {{ devicetype.slug }} -
    Part Number{{ devicetype.part_number|placeholder }}
    Height (U){{ devicetype.u_height }}
    Full Depth - {% if devicetype.is_full_depth %} - - {% else %} - - {% endif %} -
    Parent/Child - {{ devicetype.get_subdevice_role_display|placeholder }} -
    Front Image - {% if devicetype.front_image %} - - {{ devicetype.front_image.name }} - - {% else %} - - {% endif %} -
    Rear Image - {% if devicetype.rear_image %} - - {{ devicetype.rear_image.name }} - - {% else %} - - {% endif %} -
    Instances{{ devicetype.instances.count }}
    + {% plugin_left_page devicetype %}
    - {% plugin_left_page devicetype %} -
    -
    - {% include 'inc/custom_fields_panel.html' with obj=devicetype %} - {% include 'extras/inc/tags_panel.html' with tags=devicetype.tags.all url='dcim:devicetype_list' %} -
    -
    - Comments +
    + {% include 'inc/custom_fields_panel.html' with obj=devicetype %} + {% include 'extras/inc/tags_panel.html' with tags=devicetype.tags.all url='dcim:devicetype_list' %} +
    +
    + Comments +
    +
    + {% if devicetype.comments %} + {{ devicetype.comments|render_markdown }} + {% else %} + None + {% endif %} +
    -
    - {% if devicetype.comments %} - {{ devicetype.comments|render_markdown }} - {% else %} - None - {% endif %} + {% plugin_right_page devicetype %} +
    +
    +
    +
    + {% plugin_full_width_page devicetype %} +
    +
    +
    +
    + +
    +
    + {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' %} +
    +
    + {% include 'dcim/inc/devicetype_component_table.html' with table=front_port_table title='Front Ports' %} +
    +
    + {% include 'dcim/inc/devicetype_component_table.html' with table=rear_port_table title='Rear Ports' %} +
    +
    + {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' %} +
    +
    + {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' %} +
    +
    + {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' %} +
    +
    + {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' %} +
    +
    + {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' %} +
    - {% plugin_right_page devicetype %}
    -
    -{% if devicetype.consoleport_templates.exists or devicetype.powerport_templates.exists %} -
    -
    - {% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:consoleporttemplate_add' edit_url='dcim:consoleporttemplate_bulk_edit' delete_url='dcim:consoleporttemplate_bulk_delete' %} -
    -
    - {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:powerporttemplate_add' edit_url='dcim:powerporttemplate_bulk_edit' delete_url='dcim:powerporttemplate_bulk_delete' %} -
    -
    -{% endif %} -
    -
    - {% plugin_full_width_page devicetype %} -
    -
    -{% if devicetype.is_parent_device or devicebay_table.rows %} -
    -
    - {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicebaytemplate_add' edit_url=None delete_url='dcim:devicebaytemplate_bulk_delete' %} -
    -
    -{% endif %} -{% if devicetype.consoleserverport_templates.exists %} -
    -
    - {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:consoleserverporttemplate_add' edit_url='dcim:consoleserverporttemplate_bulk_edit' delete_url='dcim:consoleserverporttemplate_bulk_delete' %} -
    -
    -{% endif %} -{% if devicetype.poweroutlet_templates.exists %} -
    -
    - {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:poweroutlettemplate_add' edit_url='dcim:poweroutlettemplate_bulk_edit' delete_url='dcim:poweroutlettemplate_bulk_delete' %} -
    -
    -{% endif %} -{% if devicetype.interface_templates.exists %} -
    -
    - {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:interfacetemplate_add' edit_url='dcim:interfacetemplate_bulk_edit' delete_url='dcim:interfacetemplate_bulk_delete' %} -
    -
    -{% endif %} -{% if devicetype.frontport_templates.exists or devicetype.rearport_templates.exists %} -
    -
    - {% include 'dcim/inc/devicetype_component_table.html' with table=front_port_table title='Front Ports' add_url='dcim:frontporttemplate_add' edit_url='dcim:frontporttemplate_bulk_edit' delete_url='dcim:frontporttemplate_bulk_delete' %} -
    -
    - {% include 'dcim/inc/devicetype_component_table.html' with table=rear_port_table title='Rear Ports' add_url='dcim:rearporttemplate_add' edit_url='dcim:rearporttemplate_bulk_edit' delete_url='dcim:rearporttemplate_bulk_delete' %} -
    -
    -{% endif %} {% endblock %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html new file mode 100644 index 000000000..e3c7bcad6 --- /dev/null +++ b/netbox/templates/dcim/frontport.html @@ -0,0 +1,106 @@ +{% extends 'dcim/device_component.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    + Front Port +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ instance.device }} +
    Name{{ instance.name }}
    Label{{ instance.label|placeholder }}
    Type{{ instance.get_type_display }}
    Rear Port + {{ instance.rear_port }} +
    Rear Port Position{{ instance.rear_port_position }}
    Description{{ instance.description|placeholder }}
    +
    + {% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %} + {% plugin_left_page instance %} +
    +
    +
    +
    + Connection +
    + {% if instance.cable %} + + + + + + + + + +
    Cable + {{ instance.cable }} + + + +
    Connection Status + {% if instance.cable.status %} + {{ instance.cable.get_status_display }} + {% else %} + {{ instance.cable.get_status_display }} + {% endif %} +
    + {% else %} +
    + Not connected + {% if perms.dcim.add_cable %} + + + + + {% endif %} +
    + {% endif %} +
    + {% plugin_right_page instance %} +
    +
    +
    +
    + {% plugin_full_width_page instance %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html index a52cc302e..98eca17d2 100644 --- a/netbox/templates/dcim/inc/cable_form.html +++ b/netbox/templates/dcim/inc/cable_form.html @@ -29,5 +29,6 @@ {% endif %}
    + {% render_field form.tags %}
    diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 0711ff121..1ba3d05c9 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -16,7 +16,9 @@ Component - {{ termination }} + + {{ termination }} + {% else %} {# Circuit termination #} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 9089f19b4..dc2111b8a 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -1,8 +1,16 @@ + {# Checkbox #} + {% if perms.dcim.change_consoleport or perms.dcim.delete_consoleport %} + + + + {% endif %} + {# Name #} - {{ cp }} + + {{ cp }} {# Type #} @@ -10,8 +18,6 @@ {% if cp.type %}{{ cp.get_type_display }}{% else %}—{% endif %} - - {# Description #} {{ cp.description }} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 0d649f812..dcf168ae7 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -11,7 +11,8 @@ {# Name #} - {{ csp }} + + {{ csp }} {# Type #} diff --git a/netbox/templates/dcim/inc/device_component_table.html b/netbox/templates/dcim/inc/device_component_table.html new file mode 100644 index 000000000..a0bb8f82b --- /dev/null +++ b/netbox/templates/dcim/inc/device_component_table.html @@ -0,0 +1,40 @@ +{% load helpers %} +{% load perms %} +
    + {% csrf_token %} +
    +
    + {{ title }} +
    + + {% for obj in components %} + {% include component_template %} + {% endfor %} +
    + +
    +
    diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index 70ce7e8df..ee6a66d8f 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -9,7 +9,8 @@ {# Name #} - {{ devicebay.name }} + + {{ devicebay.name }} {# Status #} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/inc/devicetype_component_table.html index 010749b93..135facc73 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -1,3 +1,4 @@ +{% load helpers %} {% if perms.dcim.change_devicetype %}
    {% csrf_token %} @@ -8,19 +9,18 @@ {% include 'responsive_table.html' %}
    - {% for tag in tags %} + {% for tag in tags.all %} {% tag tag url %} {% empty %} No tags assigned diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 8ddf74eca..355c21c00 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -3,7 +3,7 @@ {% block title %}{{ report.name }}{% endblock %} -{% block content %} +{% block header %}
    - {% if perms.extras.add_reportresult %} + {% if perms.extras.run_report %}
    - + {% csrf_token %} - {{ run_form }} - +
    {% endif %} -

    {{ report.name }}{% include 'extras/inc/report_label.html' with result=report.result %}

    +

    {{ report.name }}

    + {% if report.description %} +

    {{ report.description }}

    + {% endif %} +{% endblock %} + +{% block content %}
    - {% if report.description %} -

    {{ report.description }}

    - {% endif %} - {% if report.result %} -

    Last run: {{ report.result.created }}

    - {% endif %} - {% if report.result %} -
    -
    - Report Methods -
    - - {% for method, data in report.result.data.items %} - - - - - {% endfor %} -
    {{ method }} - - - - -
    -
    -
    -
    - Report Results -
    - - - - - - - - - - - {% for method, data in report.result.data.items %} - - - - {% for time, level, obj, url, message in data.log %} - - - - - - - {% endfor %} - {% endfor %} - -
    TimeLevelObjectMessage
    - {{ method }} -
    {{ time }} - - - {% if obj and url %} - {{ obj }} - {% elif obj %} - {{ obj }} - {% endif %} - {{ message }}
    -
    - {% else %} -
    No results are available for this report. Please run the report first.
    - {% endif %} -
    -
    {% if report.result %} + Last run: + {{ report.result.created }} + {% endif %}
    diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 7de085974..528eb6157 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -6,7 +6,7 @@
    {% if reports %} - {% for module, module_reports in reports %} + {% for module, module_reports in reports %}

    {{ module|bettertitle }}

    @@ -15,27 +15,48 @@ + {% for report in module_reports %} - - {% if report.result %} - - {% else %} - + + + {% for method, stats in report.result.data.items %} -
    Status Description Last Run
    - {{ report.name }} + + {{ report.name }} + - {% include 'extras/inc/report_label.html' with result=report.result %} + {% include 'extras/inc/job_label.html' with result=report.result %} {{ report.description|default:"" }}{{ report.result.created }}Never{{ report.description|placeholder }} + {% if report.result %} + {{ report.result.created }} + {% else %} + Never + {% endif %} + + {% if perms.extras.run_report %} +
    +
    + {% csrf_token %} + +
    +
    {% endif %} +
    + {{ method }} @@ -66,10 +87,10 @@
      {% for report in module_reports %} - + {{ report.name }}
      - {% include 'extras/inc/report_label.html' with result=report.result %} + {% include 'extras/inc/job_label.html' with result=report.result %}
      {% endfor %} diff --git a/netbox/templates/extras/report_result.html b/netbox/templates/extras/report_result.html new file mode 100644 index 000000000..80715f2aa --- /dev/null +++ b/netbox/templates/extras/report_result.html @@ -0,0 +1,100 @@ +{% extends 'extras/report.html' %} +{% load helpers %} +{% load static %} + +{% block title %}{{ report.name }} - {{ result.get_status_display }}{% endblock %} + +{% block content %} +
      +
      +

      + Run: {{ result.created }} + {% if result.completed %} + Duration: {{ result.duration }} + {% else %} + + {% endif %} + {% include 'extras/inc/job_label.html' with result=result %} +

      + {% if result.completed %} +
      +
      + Report Methods +
      + + {% for method, data in result.data.items %} + + + + + {% endfor %} +
      {{ method }} + + + + +
      +
      +
      +
      + Report Results +
      + + + + + + + + + + + {% for method, data in result.data.items %} + + + + {% for time, level, obj, url, message in data.log %} + + + + + + + {% endfor %} + {% endfor %} + +
      TimeLevelObjectMessage
      + {{ method }} +
      {{ time }} + + + {% if obj and url %} + {{ obj }} + {% elif obj %} + {{ obj }} + {% endif %} + {{ message }}
      +
      + {% else %} +
      Pending results
      + {% endif %} +
      +
      +{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 01dc4bfa5..76a86b435 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -21,51 +21,12 @@ -
    • Source
    - {% if execution_time or script.log %} -
    -
    -
    -
    - Script Log -
    - - - - - - - {% for level, message in script.log %} - - - - - - {% empty %} - - - - {% endfor %} -
    LineLevelMessage
    {{ forloop.counter }}{% log_level level %}{{ message|render_markdown }}
    - No log output -
    - {% if execution_time %} - - {% endif %} -
    -
    -
    - {% endif %}
    {% if not perms.extras.run_script %} @@ -100,9 +61,6 @@
    -
    -
    {{ output }}
    -

    {{ script.filename }}

    {{ script.source }}
    diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index a1b97cfc2..caf9c34fd 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -4,15 +4,17 @@ {% block content %}

    {% block title %}Scripts{% endblock %}

    -
    +
    {% if scripts %} {% for module, module_scripts in scripts.items %}

    {{ module|bettertitle }}

    - - + + + + @@ -21,7 +23,17 @@ + + {% if script.result %} + + {% else %} + + {% endif %} {% endfor %} @@ -34,5 +46,26 @@ {% endif %} +
    + {% if scripts %} +
    + {% for module, module_scripts in scripts.items %} +
    + {{ module|bettertitle }} +
    + + {% endfor %} +
    + {% endif %} +
    {% endblock %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html new file mode 100644 index 000000000..c9fc348ee --- /dev/null +++ b/netbox/templates/extras/script_result.html @@ -0,0 +1,113 @@ +{% extends 'base.html' %} +{% load helpers %} +{% load form_helpers %} +{% load log_levels %} +{% load static %} + +{% block title %}{{ script }} - {{ result.get_status_display }}{% endblock %} + +{% block content %} +
    +
    + +
    +
    +

    {{ script }}

    +

    {{ script.Meta.description }}

    + +
    +

    + Run: {{ result.created }} + {% if result.completed %} + Duration: {{ result.duration }} + {% else %} + + {% endif %} + {% include 'extras/inc/job_label.html' with result=result %} +

    +
    + {% if result.completed %} +
    +
    +
    +
    + Script Log +
    +
    NameDescriptionNameStatusDescriptionLast Run
    {{ script }} + {% include 'extras/inc/job_label.html' with result=script.result %} + {{ script.Meta.description }} + {{ script.result.created }} + Never
    + + + + + + {% for log in result.data.log %} + + + + + + {% empty %} + + + + {% endfor %} +
    LineLevelMessage
    {{ forloop.counter }}{% log_level log.status %}{{ log.message|render_markdown }}
    + No log output +
    + {% if execution_time %} + + {% endif %} +
    +
    +
    + {% else %} +
    +
    +
    Pending results
    +
    +
    + {% endif %} +
    +
    +
    {{ result.data.output }}
    +
    +
    +

    {{ script.filename }}

    +
    {{ script.source }}
    +
    +
    +{% endblock %} + + +{% block javascript %} + + +{% endblock %} \ No newline at end of file diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 0c20bcbdc..ff54a4800 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -85,7 +85,7 @@
    Description - {{ tag.description }} + {{ tag.description|placeholder }}
    diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 50a411048..85332ae1b 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -280,8 +280,8 @@ {% for result in report_results %} - - + + {% endfor %}
    {{ result.report }}{% include 'extras/inc/report_label.html' %}{{ result.name }}{% include 'extras/inc/job_label.html' %}
    diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index c862b1faa..23808bc04 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -102,6 +102,12 @@
  • + {% if perms.extras.add_tag %} +
    + + +
    + {% endif %} Tags @@ -138,6 +144,12 @@ Platforms + {% if perms.dcim.add_virtualchassis %} +
    + + +
    + {% endif %} Virtual Chassis
  • @@ -161,16 +173,6 @@ Manufacturers
  • - - - {% if perms.dcim.add_inventoryitem %} -
    - -
    - {% endif %} - Inventory Items - -
  • {% if perms.dcim.add_cable %} @@ -255,6 +257,14 @@ {% endif %} Device Bays + + {% if perms.dcim.add_inventoryitem %} +
    + +
    + {% endif %} + Inventory Items + + + {% if perms.virtualization.add_vminterface %} +
    + +
    + {% endif %} + Interfaces +
  • diff --git a/netbox/templates/inc/paginator.html b/netbox/templates/inc/paginator.html index c0baef070..50d7e06d3 100644 --- a/netbox/templates/inc/paginator.html +++ b/netbox/templates/inc/paginator.html @@ -9,7 +9,7 @@ {% endif %} {% for p in page.smart_pages %} {% if p %} - {{ p }} + {{ p }} {% else %}
  • {% endif %} diff --git a/netbox/templates/inc/search_panel.html b/netbox/templates/inc/search_panel.html index b156791ff..5302f71b9 100644 --- a/netbox/templates/inc/search_panel.html +++ b/netbox/templates/inc/search_panel.html @@ -7,33 +7,36 @@
    - {% for field in filter_form %} -
    - {% if field.name == "q" %} -
    - - - - -
    - {% elif field|widget_type == 'checkboxinput' %} - - {% else %} - {{ field.label_tag }} - {{ field }} - {% endif %} -
    - {% endfor %} -
    - - - Clear - + {% for field in filter_form.hidden_fields %} + {{ field }} + {% endfor %} + {% for field in filter_form.visible_fields %} +
    + {% if field.name == "q" %} +
    + + + + +
    + {% elif field|widget_type == 'checkboxinput' %} + + {% else %} + {{ field.label_tag }} + {{ field }} + {% endif %}
    + {% endfor %} +
    + + + Clear + +
    diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 6eba1a5e6..ff83061cf 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -120,8 +120,8 @@ Assignment - {% if ipaddress.interface %} - {{ ipaddress.interface.parent }} ({{ ipaddress.interface }}) + {% if ipaddress.assigned_object %} + {{ ipaddress.assigned_object.parent }} ({{ ipaddress.assigned_object }}) {% else %} {% endif %} @@ -132,8 +132,8 @@ {% if ipaddress.nat_inside %} {{ ipaddress.nat_inside }} - {% if ipaddress.nat_inside.interface %} - ({{ ipaddress.nat_inside.interface.parent }}) + {% if ipaddress.nat_inside.assigned_object %} + ({{ ipaddress.nat_inside.assigned_object.parent }}) {% endif %} {% else %} None diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index d8902595a..e7894dbad 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -28,39 +28,49 @@ {% render_field form.tenant %}
    - {% if obj.interface %} -
    -
    - Interface Assignment -
    -
    -
    - -
    -

    - {{ obj.interface.parent }} -

    +
    +
    + Interface Assignment +
    +
    + {% with vm_tab_active=form.initial.vminterface %} + +
    +
    + {% render_field form.device %} + {% render_field form.interface %} +
    +
    + {% render_field form.virtual_machine %} + {% render_field form.vminterface %}
    - {% render_field form.interface %} - {% render_field form.primary_for_parent %} -
    + {% endwith %} + {% render_field form.primary_for_parent %}
    - {% endif %} +
    NAT IP (Inside)
    -
    +
    {% render_field form.nat_site %} {% render_field form.nat_rack %} {% render_field form.nat_device %}
    - @@ -82,7 +92,3 @@
    {% endif %} {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index 00f0b7fe9..c4c6ea157 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -3,7 +3,18 @@ {% block buttons %}
    - Collapse - Expand +
    {% endblock %} diff --git a/netbox/templates/secrets/inc/secret_tr.html b/netbox/templates/secrets/inc/secret_tr.html index 3c60928ae..2af609289 100644 --- a/netbox/templates/secrets/inc/secret_tr.html +++ b/netbox/templates/secrets/inc/secret_tr.html @@ -1,23 +1,16 @@ -{% load secret_helpers %} {{ secret.role }} {{ secret.name }} ******** - {% if secret|decryptable_by:request.user %} - - - - {% else %} - - {% endif %} + + + diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 3ddb2fe98..841d9843a 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -2,7 +2,6 @@ {% load buttons %} {% load custom_links %} {% load helpers %} -{% load secret_helpers %} {% load static %} {% load plugins %} @@ -70,38 +69,31 @@ {% plugin_left_page secret %}
    - {% if secret|decryptable_by:request.user %} -
    -
    - Secret Data -
    -
    -
    - {% csrf_token %} -
    -
    -
    Secret
    -
    ********
    -
    - - - -
    +
    +
    + Secret Data +
    +
    +
    + {% csrf_token %} +
    +
    +
    Secret
    +
    ********
    +
    + + +
    - {% else %} -
    - - You do not have permission to decrypt this secret. -
    - {% endif %} +
    {% include 'extras/inc/tags_panel.html' with tags=secret.tags.all url='secrets:secret_list' %} {% plugin_right_page secret %}
    diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html index cb3935521..0cb1eefef 100644 --- a/netbox/templates/secrets/secret_edit.html +++ b/netbox/templates/secrets/secret_edit.html @@ -1,7 +1,6 @@ {% extends 'base.html' %} {% load static %} {% load form_helpers %} -{% load secret_helpers %} {% block content %}
    @@ -9,7 +8,7 @@ {{ form.private_key }}
    -

    {% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %}

    +

    {% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add a Secret{% endif %}{% endblock %}

    {% if form.non_field_errors %}
    Errors
    @@ -30,17 +29,17 @@
    Secret Data
    - {% if secret.pk and secret|decryptable_by:request.user %} + {% if obj.pk %}
    -

    ********

    +

    ********

    - -
    @@ -69,9 +68,9 @@
    - {% if secret.pk %} + {% if obj.pk %} - Cancel + Cancel {% else %} diff --git a/netbox/templates/users/base.html b/netbox/templates/users/base.html index 15d81ae0f..e9b4532e1 100644 --- a/netbox/templates/users/base.html +++ b/netbox/templates/users/base.html @@ -9,21 +9,21 @@
    diff --git a/netbox/templates/utilities/obj_bulk_import.html b/netbox/templates/utilities/obj_bulk_import.html index 4359d49a6..9d63788ba 100644 --- a/netbox/templates/utilities/obj_bulk_import.html +++ b/netbox/templates/utilities/obj_bulk_import.html @@ -66,6 +66,24 @@ {% endif %} + {% if field.choice_values %} + + + {% endif %} {% if field.help_text %} {{ field.help_text }}
    {% elif field.label %} diff --git a/netbox/templates/dcim/bulk_rename.html b/netbox/templates/utilities/obj_bulk_rename.html similarity index 100% rename from netbox/templates/dcim/bulk_rename.html rename to netbox/templates/utilities/obj_bulk_rename.html diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index 85ff050ed..47f11e1c1 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -9,10 +9,10 @@ {% endif %} {% if permissions.add and 'add' in action_buttons %} - {% add_button content_type.model_class|url_name:"add" %} + {% add_button content_type.model_class|validated_viewname:"add" %} {% endif %} {% if permissions.add and 'import' in action_buttons %} - {% import_button content_type.model_class|url_name:"import" %} + {% import_button content_type.model_class|validated_viewname:"import" %} {% endif %} {% if 'export' in action_buttons %} {% export_button content_type %} @@ -21,7 +21,7 @@

    {% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}

    - {% with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %} + {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %} {% if permissions.change or permissions.delete %} {% csrf_token %} diff --git a/netbox/templates/utilities/render_field.html b/netbox/templates/utilities/render_field.html index e69296873..b383ae4d3 100644 --- a/netbox/templates/utilities/render_field.html +++ b/netbox/templates/utilities/render_field.html @@ -16,14 +16,6 @@ Set null {% endif %} - {% if field.errors %} -
      - {% for error in field.errors %} -
    • {{ error }}
    • - {% endfor %} -
    - {% endif %} -
    {% elif field|widget_type == 'textarea' and not field.label %}
    {{ field }} @@ -35,14 +27,6 @@ {% if field.help_text %} {{ field.help_text|safe }} {% endif %} - {% if field.errors %} -
      - {% for error in field.errors %} -
    • {{ error }}
    • - {% endfor %} -
    - {% endif %} -
    {% else %}
    @@ -55,13 +39,15 @@ {% if field.help_text %} {{ field.help_text|safe }} {% endif %} - {% if field.errors %} -
      - {% for error in field.errors %} -
    • {{ error }}
    • - {% endfor %} -
    - {% endif %} -
    {% endif %} + {% if field.errors %} +
      + {% for error in field.errors %} + {# Embed an HTML comment indicating the error for extraction by tests #} + +
    • {{ error }}
    • + {% endfor %} +
    + {% endif %} +
    diff --git a/netbox/templates/utilities/templatetags/badge.html b/netbox/templates/utilities/templatetags/badge.html new file mode 100644 index 000000000..3b0ddd7d7 --- /dev/null +++ b/netbox/templates/utilities/templatetags/badge.html @@ -0,0 +1 @@ +{% if value or show_empty %}{{ value }}{% endif %} diff --git a/netbox/templates/virtualization/cluster_add_devices.html b/netbox/templates/virtualization/cluster_add_devices.html index 397f53d01..d53fc3a03 100644 --- a/netbox/templates/virtualization/cluster_add_devices.html +++ b/netbox/templates/virtualization/cluster_add_devices.html @@ -35,33 +35,3 @@
    {% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/virtualization/inc/vminterface.html b/netbox/templates/virtualization/inc/vminterface.html new file mode 100644 index 000000000..5410fba7a --- /dev/null +++ b/netbox/templates/virtualization/inc/vminterface.html @@ -0,0 +1,141 @@ +{% load helpers %} + + + {# Checkbox #} + {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} + + + + {% endif %} + + {# Name #} + + {{ iface }} + + + {# MAC address #} + + {{ iface.mac_address|default:"—" }} + + + {# MTU #} + {{ iface.mtu|default:"—" }} + + {# 802.1Q mode #} + {{ iface.get_mode_display|default:"—" }} + + {# Description/tags #} + + {% if iface.description %} + {{ iface.description }}
    + {% endif %} + {% for tag in iface.tags.all %} + {% tag tag %} + {% empty %} + {% if not iface.description %}—{% endif %} + {% endfor %} + + + {# Buttons #} + + {% if show_interface_graphs %} + + {% endif %} + {% if perms.ipam.add_ipaddress %} + + + + {% endif %} + {% if perms.virtualization.change_interface %} + + + + {% endif %} + {% if perms.virtualization.delete_interface %} + + + + {% endif %} + + + +{% with ipaddresses=iface.ip_addresses.all %} + {% if ipaddresses %} + + {# Placeholder #} + {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %} + + {% endif %} + + {# IP addresses table #} + + + + + + + + + + + + {% for ip in iface.ip_addresses.all %} + + + {# IP address #} + + + {# Primary/status/role #} + + + {# VRF #} + + + {# Description #} + + + {# Buttons #} + + + + {% endfor %} +
    IP AddressStatus/RoleVRFDescription
    + {{ ip }} + + {% if virtualmachine.primary_ip4 == ip or virtualmachine.primary_ip6 == ip %} + Primary + {% endif %} + {{ ip.get_status_display }} + {% if ip.role %} + {{ ip.get_role_display }} + {% endif %} + + {% if ip.vrf %} + {{ ip.vrf.name }} + {% else %} + Global + {% endif %} + + {% if ip.description %} + {{ ip.description }} + {% else %} + + {% endif %} + + {% if perms.ipam.change_ipaddress %} + + + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + + {% endif %} +
    + + + {% endif %} +{% endwith %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index ea8f4fedb..ea33aa460 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -29,6 +29,11 @@
    + {% if perms.virtualization.add_vminterface %} + + Add Interfaces + + {% endif %} {% plugin_buttons virtualmachine %} {% if perms.virtualization.add_virtualmachine %} {% clone_button virtualmachine %} @@ -248,7 +253,7 @@
    - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
    {% csrf_token %} @@ -268,22 +273,20 @@ - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %} {% endif %} - - + - - + {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' with device=virtualmachine %} + {% include 'virtualization/inc/vminterface.html' %} {% empty %} @@ -291,24 +294,24 @@ {% endfor %}
    NameLAGDescriptionMAC Address MTU ModeCableConnectionDescription
    — No interfaces defined —
    - {% if perms.dcim.add_interface or perms.dcim.delete_interface %} + {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %} {% endif %}
    - {% if perms.dcim.delete_interface %} + {% if perms.virtualization.delete_vminterface %} {% endif %}
    diff --git a/netbox/templates/virtualization/virtualmachine_component_add.html b/netbox/templates/virtualization/virtualmachine_component_add.html index 34a8f3c3d..aafefffa1 100644 --- a/netbox/templates/virtualization/virtualmachine_component_add.html +++ b/netbox/templates/virtualization/virtualmachine_component_add.html @@ -22,12 +22,6 @@ {{ component_type|bettertitle }}
    -
    - -
    -

    {{ parent }}

    -
    -
    {% render_form form %}
    diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html index 74839b250..f8ee77626 100644 --- a/netbox/templates/virtualization/virtualmachine_list.html +++ b/netbox/templates/virtualization/virtualmachine_list.html @@ -7,7 +7,7 @@ Add Components
    {% endif %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html new file mode 100644 index 000000000..8d46b52fd --- /dev/null +++ b/netbox/templates/virtualization/vminterface.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% load helpers %} + +{% block header %} +
    +
    + +
    +
    +
    + {% if perms.virtualization.change_vminterface %} + + Edit + + {% endif %} + {% if perms.virtualization.delete_vminterface %} + + Delete + + {% endif %} +
    +

    {% block title %}{{ vminterface.virtual_machine }} / {{ vminterface.name }}{% endblock %}

    + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Interface +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Virtual Machine + {{ vminterface.virtual_machine }} +
    Name{{ vminterface.name }}
    Enabled + {% if vminterface.enabled %} + + {% else %} + + {% endif %} +
    Description{{ vminterface.description|placeholder }}
    MTU{{ vminterface.mtu|placeholder }}
    MAC Address{{ vminterface.mac_address|placeholder }}
    802.1Q Mode{{ vminterface.get_mode_display }}
    +
    +
    +
    + {% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %} +
    +
    +
    +
    + {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
    +
    +
    +
    + {% include 'panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    +{% endblock %} diff --git a/netbox/templates/virtualization/interface_edit.html b/netbox/templates/virtualization/vminterface_edit.html similarity index 91% rename from netbox/templates/virtualization/interface_edit.html rename to netbox/templates/virtualization/vminterface_edit.html index 437b960c9..6b0313284 100644 --- a/netbox/templates/virtualization/interface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -21,7 +21,7 @@ {% block buttons %} {% if obj.pk %} - + {% else %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 80780dba3..369d5eb1b 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -16,10 +16,11 @@ __all__ = [ class NestedTenantGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') tenant_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = TenantGroup - fields = ['id', 'url', 'name', 'slug', 'tenant_count'] + fields = ['id', 'url', 'name', 'slug', 'tenant_count', '_depth'] class NestedTenantSerializer(WritableNestedSerializer): diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 9c7a099e4..4467b050b 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers -from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from extras.api.customfields import CustomFieldModelSerializer +from extras.api.serializers import TaggedObjectSerializer from tenancy.models import Tenant, TenantGroup from utilities.api import ValidatedModelSerializer from .nested_serializers import * @@ -12,17 +12,19 @@ from .nested_serializers import * # class TenantGroupSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') parent = NestedTenantGroupSerializer(required=False, allow_null=True) tenant_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = TenantGroup - fields = ['id', 'name', 'slug', 'parent', 'description', 'tenant_count'] + fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'tenant_count', '_depth'] -class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): +class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') group = NestedTenantGroupSerializer(required=False) - tags = TagListSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) ipaddress_count = serializers.IntegerField(read_only=True) @@ -37,7 +39,7 @@ class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): class Meta: model = Tenant fields = [ - 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'id', 'url', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', ] diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 645cc2edc..ad4424005 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,18 +1,9 @@ -from rest_framework import routers - +from utilities.api import OrderedDefaultRouter from . import views -class TenancyRootView(routers.APIRootView): - """ - Tenancy API root view - """ - def get_view_name(self): - return 'Tenancy' - - -router = routers.DefaultRouter() -router.APIRootView = TenancyRootView +router = OrderedDefaultRouter() +router.APIRootView = views.TenancyRootView # Tenants router.register('tenant-groups', views.TenantGroupViewSet) diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 148058a33..065d3a9f3 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,3 +1,5 @@ +from rest_framework.routers import APIRootView + from circuits.models import Circuit from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet @@ -10,13 +12,25 @@ from virtualization.models import VirtualMachine from . import serializers +class TenancyRootView(APIRootView): + """ + Tenancy API root view + """ + def get_view_name(self): + return 'Tenancy' + + # # Tenant Groups # class TenantGroupViewSet(ModelViewSet): - queryset = TenantGroup.objects.annotate( - tenant_count=get_subquery(Tenant, 'group') + queryset = TenantGroup.objects.add_related_count( + TenantGroup.objects.all(), + Tenant, + 'group', + 'tenant_count', + cumulative=True ) serializer_class = serializers.TenantGroupSerializer filterset_class = filters.TenantGroupFilterSet diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index bf100f43a..142333bff 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,11 +2,11 @@ from django import forms from extras.forms import ( AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm, - TagField, ) +from extras.models import Tag from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, + BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) from .models import Tenant, TenantGroup @@ -18,10 +18,7 @@ from .models import Tenant, TenantGroup class TenantGroupForm(BootstrapMixin, forms.ModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), - required=False, - widget=APISelect( - api_url="/api/tenancy/tenant-groups/" - ) + required=False ) slug = SlugField() @@ -57,7 +54,8 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): required=False ) comments = CommentField() - tags = TagField( + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), required=False ) @@ -108,10 +106,7 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=TenantGroup.objects.all(), to_field_name='slug', required=False, - widget=APISelectMultiple( - value_field="slug", - null_option=True, - ) + null_option='None' ) tag = TagFilterField(model) @@ -124,18 +119,14 @@ class TenancyForm(forms.Form): tenant_group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, - widget=APISelect( - filter_for={ - 'tenant': 'group_id', - }, - attrs={ - 'nullable': 'true', - } - ) + null_option='None' ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$tenant_group' + } ) def __init__(self, *args, **kwargs): @@ -155,20 +146,14 @@ class TenancyFilterForm(forms.Form): 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' + } ) diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 077fb6ad1..cc3abf19a 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -4,9 +4,10 @@ from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager -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.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object @@ -40,6 +41,8 @@ class TenantGroup(MPTTModel, ChangeLoggedModel): blank=True ) + objects = TreeManager() + csv_headers = ['name', 'slug', 'parent', 'description'] class Meta: @@ -104,9 +107,10 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): content_type_field='obj_type', object_id_field='obj_id' ) - tags = TaggableManager(through=TaggedItem) + objects = RestrictedQuerySet.as_manager() + csv_headers = ['name', 'slug', 'group', 'description', 'comments'] clone_fields = [ 'group', 'description', diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 147a20707..dc96b839c 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,6 +1,6 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn from .models import Tenant, TenantGroup MPTT_LINK = """ @@ -13,15 +13,6 @@ MPTT_LINK = """ """ -TENANTGROUP_ACTIONS = """ - - - -{% if perms.tenancy.change_tenantgroup %} - -{% endif %} -""" - COL_TENANT = """ {% if record.tenant %} {{ record.tenant }} @@ -44,11 +35,7 @@ class TenantGroupTable(BaseTable): tenant_count = tables.Column( verbose_name='Tenants' ) - actions = tables.TemplateColumn( - template_code=TENANTGROUP_ACTIONS, - attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='' - ) + actions = ButtonsColumn(TenantGroup, pk_field='slug') class Meta(BaseTable.Meta): model = TenantGroup diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index b06a8213a..f04b2a7ce 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -16,7 +16,7 @@ class AppTest(APITestCase): class TenantGroupTest(APIViewTestCases.APIViewTestCase): model = TenantGroup - brief_fields = ['id', 'name', 'slug', 'tenant_count', 'url'] + brief_fields = ['_depth', 'id', 'name', 'slug', 'tenant_count', 'url'] @classmethod def setUpTestData(cls): diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index ca2c2633f..5b88b84cf 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -49,13 +49,15 @@ class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]), ]) + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Tenant X', 'slug': 'tenant-x', 'group': tenant_groups[1].pk, 'description': 'A new tenant', 'comments': 'Some comments', - 'tags': 'Alpha,Bravo,Charlie', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 0218a5674..372308bb8 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -9,15 +9,16 @@ urlpatterns = [ # Tenant groups path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), - path('tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), + path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), path('tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), # Tenants path('tenants/', views.TenantListView.as_view(), name='tenant_list'), - path('tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'), + path('tenants/add/', views.TenantEditView.as_view(), name='tenant_add'), path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'), path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index afc363cd6..b129177eb 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -1,13 +1,11 @@ -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, render -from django.views.generic import View from circuits.models import Circuit from dcim.models import Site, Rack, Device, RackReservation from ipam.models import IPAddress, Prefix, VLAN, VRF from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine, Cluster from . import filters, forms, tables @@ -18,8 +16,7 @@ from .models import Tenant, TenantGroup # Tenant groups # -class TenantGroupListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'tenancy.view_tenantgroup' +class TenantGroupListView(ObjectListView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -30,61 +27,61 @@ class TenantGroupListView(PermissionRequiredMixin, ObjectListView): table = tables.TenantGroupTable -class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'tenancy.add_tenantgroup' - model = TenantGroup +class TenantGroupEditView(ObjectEditView): + queryset = TenantGroup.objects.all() model_form = forms.TenantGroupForm - default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupEditView(TenantGroupCreateView): - permission_required = 'tenancy.change_tenantgroup' +class TenantGroupDeleteView(ObjectDeleteView): + queryset = TenantGroup.objects.all() -class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'tenancy.add_tenantgroup' +class TenantGroupBulkImportView(BulkImportView): + queryset = TenantGroup.objects.all() model_form = forms.TenantGroupCSVForm table = tables.TenantGroupTable - default_return_url = 'tenancy:tenantgroup_list' -class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'tenancy.delete_tenantgroup' - queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) +class TenantGroupBulkDeleteView(BulkDeleteView): + queryset = TenantGroup.objects.add_related_count( + TenantGroup.objects.all(), + Tenant, + 'group', + 'tenant_count', + cumulative=True + ) table = tables.TenantGroupTable - default_return_url = 'tenancy:tenantgroup_list' # # Tenants # -class TenantListView(PermissionRequiredMixin, ObjectListView): - permission_required = 'tenancy.view_tenant' +class TenantListView(ObjectListView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet filterset_form = forms.TenantFilterForm table = tables.TenantTable -class TenantView(PermissionRequiredMixin, View): - permission_required = 'tenancy.view_tenant' +class TenantView(ObjectView): + queryset = Tenant.objects.prefetch_related('group') def get(self, request, slug): - tenant = get_object_or_404(Tenant, slug=slug) + tenant = get_object_or_404(self.queryset, slug=slug) stats = { - 'site_count': Site.objects.filter(tenant=tenant).count(), - 'rack_count': Rack.objects.filter(tenant=tenant).count(), - 'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(), - 'device_count': Device.objects.filter(tenant=tenant).count(), - 'vrf_count': VRF.objects.filter(tenant=tenant).count(), - 'prefix_count': Prefix.objects.filter(tenant=tenant).count(), - 'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(), - 'vlan_count': VLAN.objects.filter(tenant=tenant).count(), - 'circuit_count': Circuit.objects.filter(tenant=tenant).count(), - 'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(), - 'cluster_count': Cluster.objects.filter(tenant=tenant).count(), + 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), + 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=tenant).count(), } return render(request, 'tenancy/tenant.html', { @@ -93,43 +90,30 @@ class TenantView(PermissionRequiredMixin, View): }) -class TenantCreateView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'tenancy.add_tenant' - model = Tenant +class TenantEditView(ObjectEditView): + queryset = Tenant.objects.all() model_form = forms.TenantForm template_name = 'tenancy/tenant_edit.html' - default_return_url = 'tenancy:tenant_list' -class TenantEditView(TenantCreateView): - permission_required = 'tenancy.change_tenant' +class TenantDeleteView(ObjectDeleteView): + queryset = Tenant.objects.all() -class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'tenancy.delete_tenant' - model = Tenant - default_return_url = 'tenancy:tenant_list' - - -class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): - permission_required = 'tenancy.add_tenant' +class TenantBulkImportView(BulkImportView): + queryset = Tenant.objects.all() model_form = forms.TenantCSVForm table = tables.TenantTable - default_return_url = 'tenancy:tenant_list' -class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): - permission_required = 'tenancy.change_tenant' +class TenantBulkEditView(BulkEditView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable form = forms.TenantBulkEditForm - default_return_url = 'tenancy:tenant_list' -class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'tenancy.delete_tenant' +class TenantBulkDeleteView(BulkDeleteView): queryset = Tenant.objects.prefetch_related('group') filterset = filters.TenantFilterSet table = tables.TenantTable - default_return_url = 'tenancy:tenant_list' diff --git a/netbox/users/admin.py b/netbox/users/admin.py index 42e651712..1fac75899 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -1,12 +1,49 @@ from django import forms from django.contrib import admin from django.contrib.auth.admin import UserAdmin as UserAdmin_ -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldError, ValidationError +from django.db.models import Q -from .models import Token, UserConfig +from extras.admin import order_content_types +from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig -# Unregister the built-in UserAdmin so that we can use our custom admin view below -admin.site.unregister(User) + +# +# Inline models +# + +class ObjectPermissionInline(admin.TabularInline): + exclude = None + extra = 3 + readonly_fields = ['object_types', 'actions', 'constraints'] + verbose_name = 'Permission' + verbose_name_plural = 'Permissions' + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('objectpermission__object_types') + + @staticmethod + def object_types(instance): + # Don't call .values_list() here because we want to reference the pre-fetched object_types + return ', '.join([ot.name for ot in instance.objectpermission.object_types.all()]) + + @staticmethod + def actions(instance): + return ', '.join(instance.objectpermission.actions) + + @staticmethod + def constraints(instance): + return instance.objectpermission.constraints + + +class GroupObjectPermissionInline(ObjectPermissionInline): + model = AdminGroup.object_permissions.through + + +class UserObjectPermissionInline(ObjectPermissionInline): + model = AdminUser.object_permissions.through class UserConfigInline(admin.TabularInline): @@ -16,13 +53,52 @@ class UserConfigInline(admin.TabularInline): verbose_name = 'Preferences' -@admin.register(User) +# +# Users & groups +# + +# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below +admin.site.unregister(Group) +admin.site.unregister(User) + + +@admin.register(AdminGroup) +class GroupAdmin(admin.ModelAdmin): + fields = ('name',) + list_display = ('name', 'user_count') + ordering = ('name',) + search_fields = ('name',) + inlines = [GroupObjectPermissionInline] + + @staticmethod + def user_count(obj): + return obj.user_set.count() + + +@admin.register(AdminUser) class UserAdmin(UserAdmin_): list_display = [ 'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active' ] - inlines = (UserConfigInline,) + fieldsets = ( + (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}), + ('Groups', {'fields': ('groups',)}), + ('Status', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + }), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + filter_horizontal = ('groups',) + def get_inlines(self, request, obj): + if obj is not None: + return (UserObjectPermissionInline, UserConfigInline) + return () + + +# +# REST API tokens +# class TokenAdminForm(forms.ModelForm): key = forms.CharField( @@ -43,3 +119,173 @@ class TokenAdmin(admin.ModelAdmin): list_display = [ 'key', 'user', 'created', 'expires', 'write_enabled', 'description' ] + + +# +# Permissions +# + +class ObjectPermissionForm(forms.ModelForm): + can_view = forms.BooleanField(required=False) + can_add = forms.BooleanField(required=False) + can_change = forms.BooleanField(required=False) + can_delete = forms.BooleanField(required=False) + + class Meta: + model = ObjectPermission + exclude = [] + help_texts = { + 'actions': 'Actions granted in addition to those listed above', + 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null ' + 'to match all objects of this type. A list of multiple objects will result in a logical OR ' + 'operation.' + } + labels = { + 'actions': 'Additional actions' + } + widgets = { + 'constraints': forms.Textarea(attrs={'class': 'vLargeTextField'}) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Make the actions field optional since the admin form uses it only for non-CRUD actions + self.fields['actions'].required = False + + # Format ContentType choices + order_content_types(self.fields['object_types']) + self.fields['object_types'].choices.insert(0, ('', '---------')) + + # Order group and user fields + self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name') + self.fields['users'].queryset = self.fields['users'].queryset.order_by('username') + + # Check the appropriate checkboxes when editing an existing ObjectPermission + if self.instance.pk: + for action in ['view', 'add', 'change', 'delete']: + if action in self.instance.actions: + self.fields[f'can_{action}'].initial = True + self.instance.actions.remove(action) + + def clean(self): + object_types = self.cleaned_data.get('object_types') + constraints = self.cleaned_data.get('constraints') + + # Append any of the selected CRUD checkboxes to the actions list + if not self.cleaned_data.get('actions'): + self.cleaned_data['actions'] = list() + for action in ['view', 'add', 'change', 'delete']: + if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']: + self.cleaned_data['actions'].append(action) + + # At least one action must be specified + if not self.cleaned_data['actions']: + raise ValidationError("At least one action must be selected.") + + # Validate the specified model constraints by attempting to execute a query. We don't care whether the query + # returns anything; we just want to make sure the specified constraints are valid. + if constraints: + # Normalize the constraints to a list of dicts + if type(constraints) is not list: + constraints = [constraints] + for ct in object_types: + model = ct.model_class() + try: + model.objects.filter(*[Q(**c) for c in constraints]).exists() + except FieldError as e: + raise ValidationError({ + 'constraints': f'Invalid filter for {model}: {e}' + }) + + +class ActionListFilter(admin.SimpleListFilter): + title = 'action' + parameter_name = 'action' + + def lookups(self, request, model_admin): + options = set() + for action_list in ObjectPermission.objects.values_list('actions', flat=True).distinct(): + options.update(action_list) + return [ + (action, action) for action in sorted(options) + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(actions=[self.value()]) + + +class ObjectTypeListFilter(admin.SimpleListFilter): + title = 'object type' + parameter_name = 'object_type' + + def lookups(self, request, model_admin): + object_types = ObjectPermission.objects.values_list('id', flat=True).distinct() + content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model') + return [ + (ct.pk, ct) for ct in content_types + ] + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(object_types=self.value()) + + +@admin.register(ObjectPermission) +class ObjectPermissionAdmin(admin.ModelAdmin): + actions = ('enable', 'disable') + fieldsets = ( + (None, { + 'fields': ('name', 'description', 'enabled') + }), + ('Actions', { + 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions') + }), + ('Objects', { + 'fields': ('object_types',) + }), + ('Assignment', { + 'fields': ('groups', 'users') + }), + ('Constraints', { + 'fields': ('constraints',), + 'classes': ('monospace',) + }), + ) + filter_horizontal = ('object_types', 'groups', 'users') + form = ObjectPermissionForm + list_display = [ + 'name', 'enabled', 'list_models', 'list_users', 'list_groups', 'actions', 'constraints', 'description', + ] + list_filter = [ + 'enabled', ActionListFilter, ObjectTypeListFilter, 'groups', 'users' + ] + search_fields = ['actions', 'constraints', 'description', 'name'] + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups') + + def list_models(self, obj): + return ', '.join([f"{ct}" for ct in obj.object_types.all()]) + list_models.short_description = 'Models' + + def list_users(self, obj): + return ', '.join([u.username for u in obj.users.all()]) + list_users.short_description = 'Users' + + def list_groups(self, obj): + return ', '.join([g.name for g in obj.groups.all()]) + list_groups.short_description = 'Groups' + + # + # Admin actions + # + + def enable(self, request, queryset): + updated = queryset.update(enabled=True) + self.message_user(request, f"Enabled {updated} permissions") + + def disable(self, request, queryset): + updated = queryset.update(enabled=False) + self.message_user(request, f"Disabled {updated} permissions") diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index d1b649713..f1bcf3b37 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,18 +1,48 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers -from utilities.api import WritableNestedSerializer +from users.models import ObjectPermission +from utilities.api import ContentTypeField, WritableNestedSerializer -_all_ = [ +__all__ = [ + 'NestedGroupSerializer', + 'NestedObjectPermissionSerializer', 'NestedUserSerializer', ] -# -# Users -# +class NestedGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail') + + class Meta: + model = Group + fields = ['id', 'url', 'name'] + class NestedUserSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') class Meta: model = User - fields = ['id', 'username'] + fields = ['id', 'url', 'username'] + + +class NestedObjectPermissionSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail') + object_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + groups = serializers.SerializerMethodField(read_only=True) + users = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = ObjectPermission + fields = ['id', 'url', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions'] + + def get_groups(self, obj): + return [g.name for g in obj.groups.all()] + + def get_users(self, obj): + return [u.username for u in obj.users.all()] diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 86d350e69..1f338d6e4 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,4 +1,59 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers + +from users.models import ObjectPermission +from utilities.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer from .nested_serializers import * -# Placeholder for future serializers +class UserSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=NestedGroupSerializer, + required=False, + many=True + ) + + class Meta: + model = User + fields = ( + 'id', 'url', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', + 'groups', + ) + + +class GroupSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail') + user_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Group + fields = ('id', 'url', 'name', 'user_count') + + +class ObjectPermissionSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='users-api:objectpermission-detail') + object_types = ContentTypeField( + queryset=ContentType.objects.all(), + many=True + ) + groups = SerializedPKRelatedField( + queryset=Group.objects.all(), + serializer=NestedGroupSerializer, + required=False, + many=True + ) + users = SerializedPKRelatedField( + queryset=User.objects.all(), + serializer=NestedUserSerializer, + required=False, + many=True + ) + + class Meta: + model = ObjectPermission + fields = ( + 'id', 'url', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions', 'constraints', + ) diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py new file mode 100644 index 000000000..c52c6c87f --- /dev/null +++ b/netbox/users/api/urls.py @@ -0,0 +1,16 @@ +from utilities.api import OrderedDefaultRouter +from . import views + + +router = OrderedDefaultRouter() +router.APIRootView = views.UsersRootView + +# Users and groups +router.register('users', views.UserViewSet) +router.register('groups', views.GroupViewSet) + +# Permissions +router.register('permissions', views.ObjectPermissionViewSet) + +app_name = 'users-api' +urlpatterns = router.urls diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py new file mode 100644 index 000000000..a3536e960 --- /dev/null +++ b/netbox/users/api/views.py @@ -0,0 +1,43 @@ +from django.contrib.auth.models import Group, User +from django.db.models import Count +from rest_framework.routers import APIRootView + +from users import filters +from users.models import ObjectPermission +from utilities.api import ModelViewSet +from utilities.querysets import RestrictedQuerySet +from . import serializers + + +class UsersRootView(APIRootView): + """ + Users API root view + """ + def get_view_name(self): + return 'Users' + + +# +# Users and groups +# + +class UserViewSet(ModelViewSet): + queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username') + serializer_class = serializers.UserSerializer + filterset_class = filters.UserFilterSet + + +class GroupViewSet(ModelViewSet): + queryset = RestrictedQuerySet(model=Group).annotate(user_count=Count('user')).order_by('name') + serializer_class = serializers.GroupSerializer + filterset_class = filters.GroupFilterSet + + +# +# ObjectPermissions +# + +class ObjectPermissionViewSet(ModelViewSet): + queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users') + serializer_class = serializers.ObjectPermissionSerializer + filterset_class = filters.ObjectPermissionFilterSet diff --git a/netbox/users/filters.py b/netbox/users/filters.py new file mode 100644 index 000000000..359cf9cc7 --- /dev/null +++ b/netbox/users/filters.py @@ -0,0 +1,89 @@ +import django_filters +from django.contrib.auth.models import Group, User +from django.db.models import Q + +from users.models import ObjectPermission +from utilities.filters import BaseFilterSet + +__all__ = ( + 'GroupFilterSet', + 'ObjectPermissionFilterSet', + 'UserFilterSet', +) + + +class GroupFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + + class Meta: + model = Group + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(name__icontains=value) + + +class UserFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + group_id = django_filters.ModelMultipleChoiceFilter( + field_name='groups', + queryset=Group.objects.all(), + label='Group', + ) + group = django_filters.ModelMultipleChoiceFilter( + field_name='groups__name', + queryset=Group.objects.all(), + to_field_name='name', + label='Group (name)', + ) + + class Meta: + model = User + fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(username__icontains=value) | + Q(first_name__icontains=value) | + Q(last_name__icontains=value) | + Q(email__icontains=value) + ) + + +class ObjectPermissionFilterSet(BaseFilterSet): + user_id = django_filters.ModelMultipleChoiceFilter( + field_name='users', + queryset=User.objects.all(), + label='User', + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='users__username', + queryset=User.objects.all(), + to_field_name='username', + label='User (name)', + ) + group_id = django_filters.ModelMultipleChoiceFilter( + field_name='groups', + queryset=Group.objects.all(), + label='Group', + ) + group = django_filters.ModelMultipleChoiceFilter( + field_name='groups__name', + queryset=Group.objects.all(), + to_field_name='name', + label='Group (name)', + ) + + class Meta: + model = ObjectPermission + fields = ['id', 'name', 'enabled', 'object_types'] diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py new file mode 100644 index 000000000..2aec9e425 --- /dev/null +++ b/netbox/users/migrations/0007_proxy_group_user.py @@ -0,0 +1,46 @@ +# Generated by Django 3.0.6 on 2020-05-29 14:30 + +import django.contrib.auth.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0011_update_proxy_permissions'), + ('users', '0006_create_userconfigs'), + ] + + operations = [ + migrations.CreateModel( + name='AdminGroup', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + 'verbose_name': 'Group', + }, + bases=('auth.group',), + managers=[ + ('objects', django.contrib.auth.models.GroupManager()), + ], + ), + migrations.CreateModel( + name='AdminUser', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + 'verbose_name': 'User', + }, + bases=('auth.user',), + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py new file mode 100644 index 000000000..8ed15b6a1 --- /dev/null +++ b/netbox/users/migrations/0008_objectpermission.py @@ -0,0 +1,35 @@ +from django.conf import settings +import django.contrib.postgres.fields +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('auth', '0011_update_proxy_permissions'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0007_proxy_group_user'), + ] + + operations = [ + migrations.CreateModel( + name='ObjectPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('enabled', models.BooleanField(default=True)), + ('constraints', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)), + ('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)), + ('object_types', models.ManyToManyField(limit_choices_to=models.Q(models.Q(models.Q(_negated=True, app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']), models.Q(('app_label', 'auth'), ('model__in', ['group', 'user'])), models.Q(('app_label', 'users'), ('model__in', ['objectpermission', 'token'])), _connector='OR')), related_name='object_permissions', to='contenttypes.ContentType')), + ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')), + ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['name'], + 'verbose_name': 'permission', + }, + ), + ] diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py new file mode 100644 index 000000000..eacb63964 --- /dev/null +++ b/netbox/users/migrations/0009_replicate_permissions.py @@ -0,0 +1,74 @@ +from django.db import migrations +from django.db.models import Q + +ACTIONS = ['view', 'add', 'change', 'delete'] + + +def replicate_permissions(apps, schema_editor): + """ + Replicate all Permission assignments as ObjectPermissions. + """ + Permission = apps.get_model('auth', 'Permission') + ObjectPermission = apps.get_model('users', 'ObjectPermission') + SecretRole = apps.get_model('secrets', 'SecretRole') + + # TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups + # are combined into a single ObjectPermission instance. + for perm in Permission.objects.select_related('content_type'): + if perm.codename.split('_')[0] in ACTIONS: + action = perm.codename.split('_')[0] + elif perm.codename == 'activate_userkey': + action = 'change' + elif perm.codename == 'run_script': + action = 'run' + else: + action = perm.codename + + if perm.group_set.exists() or perm.user_set.exists(): + + # Handle replication of SecretRole user/group assignments for Secrets + if perm.codename == 'view_secret': + for secretrole in SecretRole.objects.prefetch_related('users', 'groups'): + obj_perm = ObjectPermission( + name=f'{perm.content_type.app_label}.{perm.codename} ({secretrole.name})'[:100], + actions=[action], + constraints={'role__name': secretrole.name} + ) + obj_perm.save() + obj_perm.object_types.add(perm.content_type) + # Assign only users/groups who both a) are assigned to the SecretRole and b) have the view_secret + # permission + obj_perm.groups.add( + *list(secretrole.groups.filter(permissions=perm)) + ) + obj_perm.users.add(*list(secretrole.users.filter( + Q(user_permissions=perm) | Q(groups__permissions=perm) + ))) + + else: + obj_perm = ObjectPermission( + # Copy name from original Permission object + name=f'{perm.content_type.app_label}.{perm.codename}'[:100], + actions=[action] + ) + obj_perm.save() + obj_perm.object_types.add(perm.content_type) + + if perm.group_set.exists(): + obj_perm.groups.add(*list(perm.group_set.all())) + if perm.user_set.exists(): + obj_perm.users.add(*list(perm.user_set.all())) + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_objectpermission'), + ] + + operations = [ + migrations.RunPython( + code=replicate_permissions, + reverse_code=migrations.RunPython.noop + ) + ] diff --git a/netbox/users/migrations/0010_update_jsonfield.py b/netbox/users/migrations/0010_update_jsonfield.py new file mode 100644 index 000000000..1935e58b7 --- /dev/null +++ b/netbox/users/migrations/0010_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 = [ + ('users', '0009_replicate_permissions'), + ] + + operations = [ + migrations.AlterField( + model_name='objectpermission', + name='constraints', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='userconfig', + name='data', + field=models.JSONField(default=dict), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index ea5762232..b25a75134 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -1,23 +1,55 @@ import binascii import os -from django.contrib.auth.models import User -from django.contrib.postgres.fields import JSONField +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField from django.core.validators import MinLengthValidator from django.db import models +from django.db.models import Q from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict __all__ = ( + 'AdminGroup', + 'AdminUser', + 'ObjectPermission', 'Token', 'UserConfig', ) +# +# Proxy models for admin +# + +class AdminGroup(Group): + """ + Proxy contrib.auth.models.Group for the admin UI + """ + class Meta: + verbose_name = 'Group' + proxy = True + + +class AdminUser(User): + """ + Proxy contrib.auth.models.User for the admin UI + """ + class Meta: + verbose_name = 'User' + proxy = True + + +# +# User preferences +# + class UserConfig(models.Model): """ This model stores arbitrary user-specific preferences in a JSON data structure. @@ -27,7 +59,7 @@ class UserConfig(models.Model): on_delete=models.CASCADE, related_name='config' ) - data = JSONField( + data = models.JSONField( default=dict ) @@ -130,6 +162,7 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) +@receiver(post_save, sender=AdminUser) def create_userconfig(instance, created, **kwargs): """ Automatically create a new UserConfig when a new User is created. @@ -138,6 +171,10 @@ def create_userconfig(instance, created, **kwargs): UserConfig(user=instance).save() +# +# REST API +# + class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. @@ -190,3 +227,69 @@ class Token(models.Model): if self.expires is None or timezone.now() < self.expires: return False return True + + +# +# Permissions +# + +class ObjectPermission(models.Model): + """ + A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects + identified by ORM query parameters. + """ + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + enabled = models.BooleanField( + default=True + ) + object_types = models.ManyToManyField( + to=ContentType, + limit_choices_to=Q( + ~Q(app_label__in=['admin', 'auth', 'contenttypes', 'sessions', 'taggit', 'users']) | + Q(app_label='auth', model__in=['group', 'user']) | + Q(app_label='users', model__in=['objectpermission', 'token']) + ), + related_name='object_permissions' + ) + groups = models.ManyToManyField( + to=Group, + blank=True, + related_name='object_permissions' + ) + users = models.ManyToManyField( + to=User, + blank=True, + related_name='object_permissions' + ) + actions = ArrayField( + base_field=models.CharField(max_length=30), + help_text="The list of actions granted by this permission" + ) + constraints = models.JSONField( + blank=True, + null=True, + help_text="Queryset filter matching the applicable objects of the selected type(s)" + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['name'] + verbose_name = "permission" + + def __str__(self): + return self.name + + def list_constraints(self): + """ + Return all constraint sets as a list (even if only a single set is defined). + """ + if type(self.constraints) is not list: + return [self.constraints] + return self.constraints diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py new file mode 100644 index 000000000..c4229bff9 --- /dev/null +++ b/netbox/users/tests/test_api.py @@ -0,0 +1,134 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.test import override_settings +from django.urls import reverse +from rest_framework import status + +from users.models import ObjectPermission +from utilities.testing import APIViewTestCases, APITestCase, disable_warnings + + +class AppTest(APITestCase): + + def test_root(self): + + url = reverse('users-api:api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class UserTest(APIViewTestCases.APIViewTestCase): + model = User + view_namespace = 'users' + brief_fields = ['id', 'url', 'username'] + create_data = [ + { + 'username': 'User_4', + }, + { + 'username': 'User_5', + }, + { + 'username': 'User_6', + }, + ] + + @classmethod + def setUpTestData(cls): + + users = ( + User(username='User_1'), + User(username='User_2'), + User(username='User_3'), + ) + User.objects.bulk_create(users) + + +class GroupTest(APIViewTestCases.APIViewTestCase): + model = Group + view_namespace = 'users' + brief_fields = ['id', 'name', 'url'] + create_data = [ + { + 'name': 'Group 4', + }, + { + 'name': 'Group 5', + }, + { + 'name': 'Group 6', + }, + ] + + @classmethod + def setUpTestData(cls): + + users = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(users) + + +class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): + model = ObjectPermission + brief_fields = ['actions', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users'] + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User 1', is_active=True), + User(username='User 2', is_active=True), + User(username='User 3', is_active=True), + ) + User.objects.bulk_create(users) + + object_type = ContentType.objects.get(app_label='dcim', model='device') + + for i in range(0, 3): + objectpermission = ObjectPermission( + name=f'Permission {i+1}', + actions=['view', 'add', 'change', 'delete'], + constraints={'name': f'TEST{i+1}'} + ) + objectpermission.save() + objectpermission.object_types.add(object_type) + objectpermission.groups.add(groups[i]) + objectpermission.users.add(users[i]) + + cls.create_data = [ + { + 'name': 'Permission 4', + 'object_types': ['dcim.site'], + 'groups': [groups[0].pk], + 'users': [users[0].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST4'}, + }, + { + 'name': 'Permission 5', + 'object_types': ['dcim.site'], + 'groups': [groups[1].pk], + 'users': [users[1].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST5'}, + }, + { + 'name': 'Permission 6', + 'object_types': ['dcim.site'], + 'groups': [groups[2].pk], + 'users': [users[2].pk], + 'actions': ['view', 'add', 'change', 'delete'], + 'constraints': {'name': 'TEST6'}, + }, + ] diff --git a/netbox/users/tests/test_filters.py b/netbox/users/tests/test_filters.py new file mode 100644 index 000000000..c3774927c --- /dev/null +++ b/netbox/users/tests/test_filters.py @@ -0,0 +1,192 @@ +from django.contrib.auth.models import Group, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from users.filters import GroupFilterSet, ObjectPermissionFilterSet, UserFilterSet +from users.models import ObjectPermission + + +class UserTestCase(TestCase): + queryset = User.objects.all() + filterset = UserFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User( + username='User1', + first_name='Hank', + last_name='Hill', + email='hank@stricklandpropane.com', + is_staff=True + ), + User( + username='User2', + first_name='Dale', + last_name='Gribble', + email='dale@dalesdeadbug.com' + ), + User( + username='User3', + first_name='Bill', + last_name='Dauterive', + email='bill.dauterive@army.mil' + ), + User( + username='User4', + first_name='Jeff', + last_name='Boomhauer', + email='boomhauer@dangolemail.com' + ), + User( + username='User5', + first_name='Debbie', + last_name='Grund', + is_active=False + ) + ) + User.objects.bulk_create(users) + + users[0].groups.set([groups[0]]) + users[1].groups.set([groups[1]]) + users[2].groups.set([groups[2]]) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_username(self): + params = {'username': ['User1', 'User2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_first_name(self): + params = {'first_name': ['Hank', 'Dale']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_last_name(self): + params = {'last_name': ['Hill', 'Gribble']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_email(self): + params = {'email': ['hank@stricklandpropane.com', 'dale@dalesdeadbug.com']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_is_staff(self): + params = {'is_staff': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_is_active(self): + params = {'is_active': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_group(self): + groups = Group.objects.all()[:2] + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].name, groups[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class GroupTestCase(TestCase): + queryset = Group.objects.all() + filterset = GroupFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Group 1', 'Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ObjectPermissionTestCase(TestCase): + queryset = ObjectPermission.objects.all() + filterset = ObjectPermissionFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + Group(name='Group 1'), + Group(name='Group 2'), + Group(name='Group 3'), + ) + Group.objects.bulk_create(groups) + + users = ( + User(username='User1'), + User(username='User2'), + User(username='User3'), + ) + User.objects.bulk_create(users) + + object_types = ( + ContentType.objects.get(app_label='dcim', model='site'), + ContentType.objects.get(app_label='dcim', model='rack'), + ContentType.objects.get(app_label='dcim', model='device'), + ) + + permissions = ( + ObjectPermission(name='Permission 1', actions=['view', 'add', 'change', 'delete']), + ObjectPermission(name='Permission 2', actions=['view', 'add', 'change', 'delete']), + ObjectPermission(name='Permission 3', actions=['view', 'add', 'change', 'delete']), + ObjectPermission(name='Permission 4', actions=['view'], enabled=False), + ObjectPermission(name='Permission 5', actions=['add'], enabled=False), + ObjectPermission(name='Permission 6', actions=['change'], enabled=False), + ObjectPermission(name='Permission 7', actions=['delete'], enabled=False), + ) + ObjectPermission.objects.bulk_create(permissions) + for i in range(0, 3): + permissions[i].groups.set([groups[i]]) + permissions[i].users.set([users[i]]) + permissions[i].object_types.set([object_types[i]]) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Permission 1', 'Permission 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_group(self): + groups = Group.objects.filter(name__in=['Group 1', 'Group 2']) + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].name, groups[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_user(self): + users = User.objects.filter(username__in=['User1', 'User2']) + params = {'user_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'user': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_object_types(self): + object_types = ContentType.objects.filter(model__in=['site', 'rack']) + params = {'object_types': [object_types[0].pk, object_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/views.py b/netbox/users/views.py index 9053d7b70..755232444 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -3,7 +3,7 @@ import logging from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash -from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in from django.http import HttpResponseForbidden, HttpResponseRedirect @@ -320,8 +320,7 @@ class TokenEditView(LoginRequiredMixin, View): }) -class TokenDeleteView(PermissionRequiredMixin, View): - permission_required = 'users.delete_token' +class TokenDeleteView(LoginRequiredMixin, View): def get(self, request, pk): diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index b4482198a..cc9789161 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -4,18 +4,30 @@ from collections import OrderedDict import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist +from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied +from django.db import transaction from django.db.models import ManyToManyField, ProtectedError from django.urls import reverse -from rest_framework.exceptions import APIException +from rest_framework import serializers +from rest_framework.exceptions import APIException, ValidationError from rest_framework.permissions import BasePermission from rest_framework.relations import PrimaryKeyRelatedField, RelatedField from rest_framework.response import Response -from rest_framework.serializers import Field, ModelSerializer, ValidationError +from rest_framework.routers import DefaultRouter from rest_framework.viewsets import ModelViewSet as _ModelViewSet from .utils import dict_to_filter_params, dynamic_import +HTTP_ACTIONS = { + 'GET': 'view', + 'OPTIONS': None, + 'HEAD': 'view', + 'POST': 'add', + 'PUT': 'change', + 'PATCH': 'change', + 'DELETE': 'delete', +} + class ServiceUnavailable(APIException): status_code = 503 @@ -31,9 +43,10 @@ def get_serializer_for_model(model, prefix=''): Dynamically resolve and return the appropriate serializer for a model. """ app_name, model_name = model._meta.label.split('.') - serializer_name = '{}.api.serializers.{}{}Serializer'.format( - app_name, prefix, model_name - ) + # Serializers for Django's auth models are in the users app + if app_name == 'auth': + app_name = 'users' + serializer_name = f'{app_name}.api.serializers.{prefix}{model_name}Serializer' try: return dynamic_import(serializer_name) except AttributeError: @@ -68,7 +81,7 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): # Fields # -class ChoiceField(Field): +class ChoiceField(serializers.Field): """ Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. @@ -102,18 +115,11 @@ class ChoiceField(Field): def to_representation(self, obj): if obj is '': return None - data = OrderedDict([ + return OrderedDict([ ('value', obj), ('label', self._choices[obj]) ]) - # TODO: Remove in v2.8 - # Include legacy numeric ID (where applicable) - if hasattr(self.choiceset, 'LEGACY_MAP') and obj in self.choiceset.LEGACY_MAP: - data['id'] = self.choiceset.LEGACY_MAP.get(obj) - - return data - def to_internal_value(self, data): if data is '': if self.allow_blank: @@ -139,14 +145,10 @@ class ChoiceField(Field): try: if data in self._choices: return data - # Check if data is a legacy numeric ID - slug = self.choiceset.id_to_slug(data) - if slug is not None: - return slug except TypeError: # Input is an unhashable type pass - raise ValidationError("{} is not a valid choice.".format(data)) + raise ValidationError(f"{data} is not a valid choice.") @property def choices(self): @@ -175,7 +177,7 @@ class ContentTypeField(RelatedField): return "{}.{}".format(obj.app_label, obj.model) -class TimeZoneField(Field): +class TimeZoneField(serializers.Field): """ Represent a pytz time zone. """ @@ -210,7 +212,7 @@ class SerializedPKRelatedField(PrimaryKeyRelatedField): # TODO: We should probably take a fresh look at exactly what we're doing with this. There might be a more elegant # way to enforce model validation on the serializer. -class ValidatedModelSerializer(ModelSerializer): +class ValidatedModelSerializer(serializers.ModelSerializer): """ Extends the built-in ModelSerializer to enforce calling clean() on the associated model during validation. """ @@ -239,7 +241,7 @@ class ValidatedModelSerializer(ModelSerializer): return data -class WritableNestedSerializer(ModelSerializer): +class WritableNestedSerializer(serializers.ModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. """ @@ -252,8 +254,9 @@ class WritableNestedSerializer(ModelSerializer): # Dictionary of related object attributes if isinstance(data, dict): params = dict_to_filter_params(data) + queryset = self.Meta.model.objects try: - return self.Meta.model.objects.get(**params) + return queryset.get(**params) except ObjectDoesNotExist: raise ValidationError( "Related object not found using the provided attributes: {}".format(params) @@ -279,8 +282,9 @@ class WritableNestedSerializer(ModelSerializer): ) # Look up object by PK + queryset = self.Meta.model.objects try: - return self.Meta.model.objects.get(pk=int(data)) + return queryset.get(pk=int(data)) except ObjectDoesNotExist: raise ValidationError( "Related object not found using the provided numeric ID: {}".format(pk) @@ -322,16 +326,26 @@ class ModelViewSet(_ModelViewSet): logger.debug(f"Using serializer {self.serializer_class}") return self.serializer_class + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + if not request.user.is_authenticated: + return + + # Restrict the view's QuerySet to allow only the permitted objects + action = HTTP_ACTIONS[request.method] + if action: + self.queryset = self.queryset.restrict(request.user, action) + def dispatch(self, request, *args, **kwargs): logger = logging.getLogger('netbox.api.views.ModelViewSet') try: return super().dispatch(request, *args, **kwargs) except ProtectedError as e: - models = [ - '{} ({})'.format(o, o._meta) for o in e.protected_objects.all() - ] - msg = 'Unable to delete object. The following dependent objects were found: {}'.format(', '.join(models)) + protected_objects = list(e.protected_objects) + msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' + msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) logger.warning(msg) return self.finalize_response( request, @@ -340,34 +354,67 @@ class ModelViewSet(_ModelViewSet): **kwargs ) - def list(self, *args, **kwargs): + def _validate_objects(self, instance): """ - Call to super to allow for caching + Check that the provided instance or list of instances are matched by the current queryset. This confirms that + any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. """ - return super().list(*args, **kwargs) - - def retrieve(self, *args, **kwargs): - """ - Call to super to allow for caching - """ - return super().retrieve(*args, **kwargs) - - # - # Logging - # + if type(instance) is list: + # Check that all instances are still included in the view's queryset + conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() + if conforming_count != len(instance): + raise ObjectDoesNotExist + else: + # Check that the instance is matched by the view's queryset + self.queryset.get(pk=instance.pk) def perform_create(self, serializer): - model = serializer.child.Meta.model if hasattr(serializer, 'many') else serializer.Meta.model + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') logger.info(f"Creating new {model._meta.verbose_name}") - return super().perform_create(serializer) + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() def perform_update(self, serializer): + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Updating {serializer.instance} (PK: {serializer.instance.pk})") - return super().perform_update(serializer) + logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") + + # Enforce object-level permissions on save() + try: + with transaction.atomic(): + instance = serializer.save() + self._validate_objects(instance) + except ObjectDoesNotExist: + raise PermissionDenied() def perform_destroy(self, instance): + model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') - logger.info(f"Deleting {instance} (PK: {instance.pk})") + logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") + return super().perform_destroy(instance) + + +# +# Routers +# + +class OrderedDefaultRouter(DefaultRouter): + + def get_api_root_view(self, api_urls=None): + """ + Wrap DRF's DefaultRouter to return an alphabetized list of endpoints. + """ + api_root_dict = OrderedDict() + list_name = self.routes[0].name + for prefix, viewset, basename in sorted(self.registry, key=lambda x: x[0]): + api_root_dict[prefix] = list_name.format(basename=basename) + + return self.APIRootView.as_view(api_root_dict=api_root_dict) diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py deleted file mode 100644 index 6342bad2b..000000000 --- a/netbox/utilities/auth_backends.py +++ /dev/null @@ -1,73 +0,0 @@ -import logging - -from django.conf import settings -from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_ -from django.contrib.auth.models import Group, Permission - - -class ViewExemptModelBackend(ModelBackend): - """ - Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view - permission enforcement. - """ - def has_perm(self, user_obj, perm, obj=None): - - # If this is a view permission, check whether the model has been exempted from enforcement - try: - app, codename = perm.split('.') - action, model = codename.split('_') - if action == 'view': - if ( - # All models are exempt from view permission enforcement - '*' in settings.EXEMPT_VIEW_PERMISSIONS - ) or ( - # This specific model is exempt from view permission enforcement - '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS - ): - return True - except ValueError: - pass - - return super().has_perm(user_obj, perm, obj) - - -class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_): - """ - Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization. - """ - @property - def create_unknown_user(self): - return settings.REMOTE_AUTH_AUTO_CREATE_USER - - def configure_user(self, request, user): - logger = logging.getLogger('netbox.authentication.RemoteUserBackend') - - # Assign default groups to the user - group_list = [] - for name in settings.REMOTE_AUTH_DEFAULT_GROUPS: - try: - group_list.append(Group.objects.get(name=name)) - except Group.DoesNotExist: - logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") - if group_list: - user.groups.add(*group_list) - logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}") - - # Assign default permissions to the user - permissions_list = [] - for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS: - try: - app_label, codename = permission_name.split('.') - permissions_list.append( - Permission.objects.get(content_type__app_label=app_label, codename=codename) - ) - except (ValueError, Permission.DoesNotExist): - logging.error( - "Invalid permission name: '{permission_name}'. Permissions must be in the form " - "._. (Example: dcim.add_site)" - ) - if permissions_list: - user.user_permissions.add(*permissions_list) - logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") - - return user diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index ce0929a8b..3d1002105 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -14,7 +14,6 @@ class ChoiceSetMeta(type): class ChoiceSet(metaclass=ChoiceSetMeta): CHOICES = list() - LEGACY_MAP = dict() @classmethod def values(cls): @@ -25,25 +24,6 @@ class ChoiceSet(metaclass=ChoiceSetMeta): # Unpack grouped choices before casting as a dict return dict(unpack_grouped_choices(cls.CHOICES)) - @classmethod - def slug_to_id(cls, slug): - """ - Return the legacy integer value corresponding to a slug. - """ - return cls.LEGACY_MAP.get(slug) - - @classmethod - def id_to_slug(cls, legacy_id): - """ - Return the slug value corresponding to a legacy integer value. - """ - if legacy_id in cls.LEGACY_MAP.values(): - # Invert the legacy map to allow lookup by integer - legacy_map = dict([ - (id, slug) for slug, id in cls.LEGACY_MAP.items() - ]) - return legacy_map.get(legacy_id) - def unpack_grouped_choices(choices): """ diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 9a3a7d028..8cf047c42 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -42,3 +42,25 @@ ADVISORY_LOCK_KEYS = { 'available-prefixes': 100100, 'available-ips': 100200, } + +# +# HTTP Request META safe copy +# + +HTTP_REQUEST_META_SAFE_COPY = [ + 'CONTENT_LENGTH', + 'CONTENT_TYPE', + 'HTTP_ACCEPT', + 'HTTP_ACCEPT_ENCODING', + 'HTTP_ACCEPT_LANGUAGE', + 'HTTP_HOST', + 'HTTP_REFERER', + 'HTTP_USER_AGENT', + 'QUERY_STRING', + 'REMOTE_ADDR', + 'REMOTE_HOST', + 'REMOTE_USER', + 'REQUEST_METHOD', + 'SERVER_NAME', + 'SERVER_PORT', +] diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 2cbe1cfc5..38297838d 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -1,19 +1,12 @@ from django.contrib.postgres.fields import JSONField from drf_yasg import openapi -from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector, SwaggerAutoSchema +from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, SwaggerAutoSchema from drf_yasg.utils import get_serializer_ref_name from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField -from taggit_serializer.serializers import TagListSerializerField -from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer from extras.api.customfields import CustomFieldsSerializer from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer -from virtualization.api.serializers import InterfaceSerializer as VirtualMachineInterfaceSerializer - -# this might be ugly, but it limits drf_yasg-specific code to this file -DeviceInterfaceSerializer.Meta.ref_name = 'DeviceInterface' -VirtualMachineInterfaceSerializer.Meta.ref_name = 'VirtualMachineInterface' class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): @@ -56,19 +49,6 @@ class SerializedPKRelatedFieldInspector(FieldInspector): return NotHandled -class TagListFieldInspector(FieldInspector): - def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): - SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type, use_references, **kwargs) - if isinstance(field, TagListSerializerField): - child_schema = self.probe_field_inspectors(field.child, ChildSwaggerType, use_references) - return SwaggerType( - type=openapi.TYPE_ARRAY, - items=child_schema, - ) - - return NotHandled - - class CustomChoiceFieldInspector(FieldInspector): def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): # this returns a callable which extracts title, description and other stuff diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py index da8510950..1d3bdbafd 100644 --- a/netbox/utilities/error_handlers.py +++ b/netbox/utilities/error_handlers.py @@ -3,35 +3,22 @@ from django.utils.html import escape from django.utils.safestring import mark_safe -def handle_protectederror(obj, request, e): +def handle_protectederror(obj_list, request, e): """ Generate a user-friendly error message in response to a ProtectedError exception. """ - try: - dep_class = e.protected_objects[0]._meta.verbose_name_plural - except IndexError: - raise e - - # Grammar for single versus multiple triggering objects - if type(obj) in (list, tuple): - err_message = "Unable to delete the requested {}. The following dependent {} were found: ".format( - obj[0]._meta.verbose_name_plural, - dep_class, - ) - else: - err_message = "Unable to delete {} {}. The following dependent {} were found: ".format( - obj._meta.verbose_name, - obj, - dep_class, - ) + protected_objects = list(e.protected_objects) + protected_count = len(protected_objects) if len(protected_objects) <= 50 else 'More than 50' + err_message = f"Unable to delete {', '.join(str(obj) for obj in obj_list)}. " \ + f"{protected_count} dependent objects were found: " # Append dependent objects to error message dependent_objects = [] - for obj in e.protected_objects: - if hasattr(obj, 'get_absolute_url'): - dependent_objects.append('{}'.format(obj.get_absolute_url(), escape(obj))) + for dependent in protected_objects[:50]: + if hasattr(dependent, 'get_absolute_url'): + dependent_objects.append(f'{escape(dependent)}') else: - dependent_objects.append(str(obj)) + dependent_objects.append(str(dependent)) err_message += ', '.join(dependent_objects) messages.error(request, mark_safe(err_message)) diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 5032aacee..77a915d9c 100644 --- a/netbox/utilities/exceptions.py +++ b/netbox/utilities/exceptions.py @@ -1,5 +1,19 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + class AbortTransaction(Exception): """ A dummy exception used to trigger a database transaction rollback. """ pass + + +class RQWorkerNotRunningException(APIException): + """ + Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker + processes are currently running. + """ + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = 'Unable to process request: RQ worker process not running.' + default_code = 'rq_worker_not_running' diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 4eb19f539..a9b851def 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -68,6 +68,6 @@ class NaturalOrderingField(models.CharField): return ( self.name, 'utilities.fields.NaturalOrderingField', - ['target_field'], + [self.target_field], kwargs, ) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py deleted file mode 100644 index 539347aaa..000000000 --- a/netbox/utilities/forms.py +++ /dev/null @@ -1,802 +0,0 @@ -import csv -import json -import re -from io import StringIO - -import django_filters -import yaml -from django import forms -from django.conf import settings -from django.contrib.postgres.forms import SimpleArrayField -from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput -from django.core.exceptions import MultipleObjectsReturned -from django.db.models import Count -from django.forms import BoundField -from django.forms.models import fields_for_model -from django.urls import reverse - -from .choices import ColorChoices, unpack_grouped_choices -from .validators import EnhancedURLValidator - -NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]' -ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]' -IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' -IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' -BOOLEAN_WITH_BLANK_CHOICES = ( - ('', '---------'), - ('True', 'Yes'), - ('False', 'No'), -) - - -def parse_numeric_range(string, base=10): - """ - Expand a numeric range (continuous or not) into a decimal or - hexadecimal list, as specified by the base parameter - '0-3,5' => [0, 1, 2, 3, 5] - '2,8-b,d,f' => [2, 8, 9, a, b, d, f] - """ - values = list() - for dash_range in string.split(','): - try: - begin, end = dash_range.split('-') - except ValueError: - begin, end = dash_range, dash_range - begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1 - values.extend(range(begin, end)) - return list(set(values)) - - -def parse_alphanumeric_range(string): - """ - Expand an alphanumeric range (continuous or not) into a list. - 'a-d,f' => [a, b, c, d, f] - '0-3,a-d' => [0, 1, 2, 3, a, b, c, d] - """ - values = [] - for dash_range in string.split(','): - try: - begin, end = dash_range.split('-') - vals = begin + end - # Break out of loop if there's an invalid pattern to return an error - if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())): - return [] - except ValueError: - begin, end = dash_range, dash_range - if begin.isdigit() and end.isdigit(): - for n in list(range(int(begin), int(end) + 1)): - values.append(n) - else: - # Value-based - if begin == end: - values.append(begin) - # Range-based - else: - # Not a valid range (more than a single character) - if not len(begin) == len(end) == 1: - raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range)) - for n in list(range(ord(begin), ord(end) + 1)): - values.append(chr(n)) - return values - - -def expand_alphanumeric_pattern(string): - """ - Expand an alphabetic pattern into a list of strings. - """ - lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1) - parsed_range = parse_alphanumeric_range(pattern) - for i in parsed_range: - if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant): - for string in expand_alphanumeric_pattern(remnant): - yield "{}{}{}".format(lead, i, string) - else: - yield "{}{}{}".format(lead, i, remnant) - - -def expand_ipaddress_pattern(string, family): - """ - Expand an IP address pattern into a list of strings. Examples: - '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24'] - '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64'] - """ - if family not in [4, 6]: - raise Exception("Invalid IP address family: {}".format(family)) - if family == 4: - regex = IP4_EXPANSION_PATTERN - base = 10 - else: - regex = IP6_EXPANSION_PATTERN - base = 16 - lead, pattern, remnant = re.split(regex, string, maxsplit=1) - parsed_range = parse_numeric_range(pattern, base) - for i in parsed_range: - if re.search(regex, remnant): - for string in expand_ipaddress_pattern(remnant, family): - yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string]) - else: - yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant]) - - -def add_blank_choice(choices): - """ - Add a blank choice to the beginning of a choices list. - """ - return ((None, '---------'),) + tuple(choices) - - -def form_from_model(model, fields): - """ - Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used - for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields - are marked as not required. - """ - form_fields = fields_for_model(model, fields=fields) - for field in form_fields.values(): - field.required = False - - return type('FormFromModel', (forms.Form,), form_fields) - - -# -# Widgets -# - -class SmallTextarea(forms.Textarea): - """ - Subclass used for rendering a smaller textarea element. - """ - pass - - -class SlugWidget(forms.TextInput): - """ - Subclass TextInput and add a slug regeneration button next to the form field. - """ - template_name = 'widgets/sluginput.html' - - -class ColorSelect(forms.Select): - """ - Extends the built-in Select widget to colorize each - This attribute can be used to reference the relevant API endpoint for a particular ContentType. - """ - option_template_name = 'widgets/select_contenttype.html' - - -class NumericArrayField(SimpleArrayField): - - def to_python(self, value): - value = ','.join([str(n) for n in parse_numeric_range(value)]) - return super().to_python(value) - - -class APISelect(SelectWithDisabled): - """ - A select widget populated via an API call - - :param api_url: API endpoint URL. Required if not set automatically by the parent field. - :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. - :param value_field: (Optional) Field to use for the option value in selection list. Defaults to `id`. - :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. - :param filter_for: (Optional) A dict of chained form fields for which this field is a filter. The key is the - name of the filter-for field (child field) and the value is the name of the query param filter. - :param conditional_query_params: (Optional) A dict of URL query params to append to the URL if the - condition is met. The condition is the dict key and is specified in the form `__`. - If the provided field value is selected for the given field, the URL query param will be appended to - the rendered URL. The value is the in the from `=`. This is useful in cases where - a particular field value dictates an additional API filter. - :param additional_query_params: Optional) A dict of query params to append to the API request. The key is the - name of the query param and the value if the query param's value. - :param null_option: If true, include the static null option in the selection list. - """ - def __init__( - self, - api_url=None, - display_field=None, - value_field=None, - disabled_indicator=None, - filter_for=None, - conditional_query_params=None, - additional_query_params=None, - null_option=False, - full=False, - *args, - **kwargs - ): - - super().__init__(*args, **kwargs) - - self.attrs['class'] = 'netbox-select2-api' - if api_url: - self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH - if full: - self.attrs['data-full'] = full - if display_field: - self.attrs['display-field'] = display_field - if value_field: - self.attrs['value-field'] = value_field - if disabled_indicator: - self.attrs['disabled-indicator'] = disabled_indicator - if filter_for: - for key, value in filter_for.items(): - self.add_filter_for(key, value) - if conditional_query_params: - for key, value in conditional_query_params.items(): - self.add_conditional_query_param(key, value) - if additional_query_params: - for key, value in additional_query_params.items(): - self.add_additional_query_param(key, value) - if null_option: - self.attrs['data-null-option'] = 1 - - def add_filter_for(self, name, value): - """ - Add details for an additional query param in the form of a data-filter-for-* attribute. - - :param name: The name of the query param - :param value: The value of the query param - """ - self.attrs['data-filter-for-{}'.format(name)] = value - - def add_additional_query_param(self, name, value): - """ - Add details for an additional query param in the form of a data-* JSON-encoded list attribute. - - :param name: The name of the query param - :param value: The value of the query param - """ - key = 'data-additional-query-param-{}'.format(name) - - values = json.loads(self.attrs.get(key, '[]')) - values.append(value) - - self.attrs[key] = json.dumps(values) - - def add_conditional_query_param(self, condition, value): - """ - Add details for a URL query strings to append to the URL if the condition is met. - The condition is specified in the form `__`. - - :param condition: The condition for the query param - :param value: The value of the query param - """ - self.attrs['data-conditional-query-param-{}'.format(condition)] = value - - -class APISelectMultiple(APISelect, forms.SelectMultiple): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.attrs['data-multiple'] = 1 - - -class DatePicker(forms.TextInput): - """ - Date picker using Flatpickr. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.attrs['class'] = 'date-picker' - self.attrs['placeholder'] = 'YYYY-MM-DD' - - -class DateTimePicker(forms.TextInput): - """ - DateTime picker using Flatpickr. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.attrs['class'] = 'datetime-picker' - self.attrs['placeholder'] = 'YYYY-MM-DD hh:mm:ss' - - -class TimePicker(forms.TextInput): - """ - Time picker using Flatpickr. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.attrs['class'] = 'time-picker' - self.attrs['placeholder'] = 'hh:mm:ss' - - -# -# Form fields -# - -class CSVDataField(forms.CharField): - """ - A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first - item is a dictionary of column headers, mapping field names to the attribute by which they match a related object - (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. - - :param from_form: The form from which the field derives its validation rules. - """ - widget = forms.Textarea - - def __init__(self, from_form, *args, **kwargs): - - form = from_form() - self.model = form.Meta.model - self.fields = form.fields - self.required_fields = [ - name for name, field in form.fields.items() if field.required - ] - - super().__init__(*args, **kwargs) - - self.strip = False - if not self.label: - self.label = '' - if not self.initial: - self.initial = ','.join(self.required_fields) + '\n' - if not self.help_text: - self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ - 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ - 'in double quotes.' - - def to_python(self, value): - - records = [] - reader = csv.reader(StringIO(value.strip())) - - # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional - # "to" field specifying how the related object is being referenced. For example, importing a Device might use a - # `site.slug` header, to indicate the related site is being referenced by its slug. - headers = {} - for header in next(reader): - if '.' in header: - field, to_field = header.split('.', 1) - headers[field] = to_field - else: - headers[header] = None - - # Parse CSV rows into a list of dictionaries mapped from the column headers. - for i, row in enumerate(reader, start=1): - if len(row) != len(headers): - raise forms.ValidationError( - f"Row {i}: Expected {len(headers)} columns but found {len(row)}" - ) - row = [col.strip() for col in row] - record = dict(zip(headers.keys(), row)) - records.append(record) - - return headers, records - - def validate(self, value): - headers, records = value - - # Validate provided column headers - for field, to_field in headers.items(): - if field not in self.fields: - raise forms.ValidationError(f'Unexpected column header "{field}" found.') - if to_field and not hasattr(self.fields[field], 'to_field_name'): - raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') - if to_field and not hasattr(self.fields[field].queryset.model, to_field): - raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') - - # Validate required fields - for f in self.required_fields: - if f not in headers: - raise forms.ValidationError(f'Required column header "{f}" not found.') - - return value - - -class CSVChoiceField(forms.ChoiceField): - """ - Invert the provided set of choices to take the human-friendly label as input, and return the database value. - """ - def __init__(self, choices, *args, **kwargs): - super().__init__(choices=choices, *args, **kwargs) - self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] - self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)} - - def clean(self, value): - value = super().clean(value) - if not value: - return '' - if value not in self.choice_values: - raise forms.ValidationError("Invalid choice: {}".format(value)) - return self.choice_values[value] - - -class CSVModelChoiceField(forms.ModelChoiceField): - """ - Provides additional validation for model choices entered as CSV data. - """ - default_error_messages = { - 'invalid_choice': 'Object not found.', - } - - def to_python(self, value): - try: - return super().to_python(value) - except MultipleObjectsReturned as e: - raise forms.ValidationError( - f'"{value}" is not a unique value for this field; multiple objects were found' - ) - - -class ExpandableNameField(forms.CharField): - """ - A field which allows for numeric range expansion - Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = """ - Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Examples: -
      -
    • [ge,xe]-0/0/[0-9]
    • -
    • e[0-3][a-d,f]
    • -
    - """ - - def to_python(self, value): - if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): - return list(expand_alphanumeric_pattern(value)) - return [value] - - -class ExpandableIPAddressField(forms.CharField): - """ - A field which allows for expansion of IP address ranges - Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Specify a numeric range to create multiple IPs.
    '\ - 'Example: 192.0.2.[1,5,100-254]/24' - - def to_python(self, value): - # Hackish address family detection but it's all we have to work with - if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): - return list(expand_ipaddress_pattern(value, 4)) - elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): - return list(expand_ipaddress_pattern(value, 6)) - return [value] - - -class CommentField(forms.CharField): - """ - A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. - """ - widget = forms.Textarea - default_label = '' - # TODO: Port Markdown cheat sheet to internal documentation - default_helptext = ' '\ - ''\ - 'Markdown syntax is supported' - - def __init__(self, *args, **kwargs): - required = kwargs.pop('required', False) - label = kwargs.pop('label', self.default_label) - help_text = kwargs.pop('help_text', self.default_helptext) - super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) - - -class SlugField(forms.SlugField): - """ - Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. - """ - def __init__(self, slug_source='name', *args, **kwargs): - label = kwargs.pop('label', "Slug") - help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") - widget = kwargs.pop('widget', SlugWidget) - super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) - self.widget.attrs['slug-source'] = slug_source - - -class TagFilterField(forms.MultipleChoiceField): - """ - A filter field for the tags of a model. Only the tags used by a model are displayed. - - :param model: The model of the filter - """ - widget = StaticSelect2Multiple - - def __init__(self, model, *args, **kwargs): - def get_choices(): - tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name') - return [(str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags] - - # Choices are fetched each time the form is initialized - super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) - - -class DynamicModelChoiceMixin: - filter = django_filters.ModelChoiceFilter - widget = APISelect - - def get_bound_field(self, form, field_name): - bound_field = BoundField(form, self, field_name) - - # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options - # will be populated on-demand via the APISelect widget. - data = bound_field.value() - if data: - field_name = getattr(self, 'to_field_name') or 'pk' - filter = self.filter(field_name=field_name) - try: - self.queryset = filter.filter(self.queryset, data) - except TypeError: - # Catch any error caused by invalid initial data passed from the user - self.queryset = self.queryset.none() - else: - self.queryset = self.queryset.none() - - # Set the data URL on the APISelect widget (if not already set) - widget = bound_field.field.widget - if not widget.attrs.get('data-url'): - app_label = self.queryset.model._meta.app_label - model_name = self.queryset.model._meta.model_name - data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) - widget.attrs['data-url'] = data_url - - return bound_field - - -class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): - """ - Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be - rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. - """ - pass - - -class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): - """ - A multiple-choice version of DynamicModelChoiceField. - """ - filter = django_filters.ModelMultipleChoiceFilter - widget = APISelectMultiple - - -class LaxURLField(forms.URLField): - """ - Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names - (e.g. http://myserver/ is valid) - """ - default_validators = [EnhancedURLValidator()] - - -class JSONField(_JSONField): - """ - Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Enter context data in JSON format.' - self.widget.attrs['placeholder'] = '' - - def prepare_value(self, value): - if isinstance(value, InvalidJSONInput): - return value - if value is None: - return '' - return json.dumps(value, sort_keys=True, indent=4) - - -# -# Forms -# - -class BootstrapMixin(forms.BaseForm): - """ - Add the base Bootstrap CSS classes to form elements. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - exempt_widgets = [ - forms.CheckboxInput, - forms.ClearableFileInput, - forms.FileInput, - forms.RadioSelect - ] - - for field_name, field in self.fields.items(): - if field.widget.__class__ not in exempt_widgets: - css = field.widget.attrs.get('class', '') - field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip() - if field.required and not isinstance(field.widget, forms.FileInput): - field.widget.attrs['required'] = 'required' - if 'placeholder' not in field.widget.attrs: - field.widget.attrs['placeholder'] = field.label - - -class ReturnURLForm(forms.Form): - """ - Provides a hidden return URL field to control where the user is directed after the form is submitted. - """ - return_url = forms.CharField(required=False, widget=forms.HiddenInput()) - - -class ConfirmationForm(BootstrapMixin, ReturnURLForm): - """ - A generic confirmation form. The form is not valid unless the confirm field is checked. - """ - confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) - - -class BulkEditForm(forms.Form): - """ - Base form for editing multiple objects in bulk - """ - def __init__(self, model, *args, **kwargs): - super().__init__(*args, **kwargs) - self.model = model - self.nullable_fields = [] - - # Copy any nullable fields defined in Meta - if hasattr(self.Meta, 'nullable_fields'): - self.nullable_fields = self.Meta.nullable_fields - - -class CSVModelForm(forms.ModelForm): - """ - ModelForm used for the import of objects in CSV format. - """ - def __init__(self, *args, headers=None, **kwargs): - super().__init__(*args, **kwargs) - - # Modify the model form to accommodate any customized to_field_name properties - if headers: - for field, to_field in headers.items(): - if to_field is not None: - self.fields[field].to_field_name = to_field - - -class ImportForm(BootstrapMixin, forms.Form): - """ - Generic form for creating an object from JSON/YAML data - """ - data = forms.CharField( - widget=forms.Textarea, - help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported." - ) - format = forms.ChoiceField( - choices=( - ('json', 'JSON'), - ('yaml', 'YAML') - ), - initial='yaml' - ) - - def clean(self): - - data = self.cleaned_data['data'] - format = self.cleaned_data['format'] - - # Process JSON/YAML data - if format == 'json': - try: - self.cleaned_data['data'] = json.loads(data) - # Check for multiple JSON objects - if type(self.cleaned_data['data']) is not dict: - raise forms.ValidationError({ - 'data': "Import is limited to one object at a time." - }) - except json.decoder.JSONDecodeError as err: - raise forms.ValidationError({ - 'data': "Invalid JSON data: {}".format(err) - }) - else: - # Check for multiple YAML documents - if '\n---' in data: - raise forms.ValidationError({ - 'data': "Import is limited to one object at a time." - }) - try: - self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader) - except yaml.error.YAMLError as err: - raise forms.ValidationError({ - 'data': "Invalid YAML data: {}".format(err) - }) - - -class TableConfigForm(BootstrapMixin, forms.Form): - """ - Form for configuring user's table preferences. - """ - columns = forms.MultipleChoiceField( - choices=[], - widget=forms.SelectMultiple( - attrs={'size': 10} - ), - help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display." - ) - - def __init__(self, table, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Initialize columns field based on table attributes - self.fields['columns'].choices = table.configurable_columns - self.fields['columns'].initial = table.visible_columns diff --git a/netbox/utilities/forms/__init__.py b/netbox/utilities/forms/__init__.py new file mode 100644 index 000000000..ce958a99e --- /dev/null +++ b/netbox/utilities/forms/__init__.py @@ -0,0 +1,5 @@ +from .constants import * +from .fields import * +from .forms import * +from .utils import * +from .widgets import * diff --git a/netbox/utilities/forms/constants.py b/netbox/utilities/forms/constants.py new file mode 100644 index 000000000..624ad5dac --- /dev/null +++ b/netbox/utilities/forms/constants.py @@ -0,0 +1,14 @@ +# String expansion patterns +NUMERIC_EXPANSION_PATTERN = r'\[((?:\d+[?:,-])+\d+)\]' +ALPHANUMERIC_EXPANSION_PATTERN = r'\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]' + +# IP address expansion patterns +IP4_EXPANSION_PATTERN = r'\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]' +IP6_EXPANSION_PATTERN = r'\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]' + +# Boolean widget choices +BOOLEAN_WITH_BLANK_CHOICES = ( + ('', '---------'), + ('True', 'Yes'), + ('False', 'No'), +) diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py new file mode 100644 index 000000000..6146e00d3 --- /dev/null +++ b/netbox/utilities/forms/fields.py @@ -0,0 +1,367 @@ +import csv +import json +import re +from io import StringIO + +import django_filters +from django import forms +from django.forms.fields import JSONField as _JSONField, InvalidJSONInput +from django.core.exceptions import MultipleObjectsReturned +from django.db.models import Count +from django.forms import BoundField +from django.urls import reverse + +from utilities.api import get_serializer_for_model +from utilities.choices import unpack_grouped_choices +from utilities.validators import EnhancedURLValidator +from . import widgets +from .constants import * +from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern + +__all__ = ( + 'CommentField', + 'CSVChoiceField', + 'CSVDataField', + 'CSVModelChoiceField', + 'DynamicModelChoiceField', + 'DynamicModelMultipleChoiceField', + 'ExpandableIPAddressField', + 'ExpandableNameField', + 'JSONField', + 'LaxURLField', + 'SlugField', + 'TagFilterField', +) + + +class CSVDataField(forms.CharField): + """ + A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first + item is a dictionary of column headers, mapping field names to the attribute by which they match a related object + (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. + """ + widget = forms.Textarea + + def __init__(self, from_form, *args, **kwargs): + + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] + + super().__init__(*args, **kwargs) + + self.strip = False + if not self.label: + self.label = '' + if not self.initial: + self.initial = ','.join(self.required_fields) + '\n' + if not self.help_text: + self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ + 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ + 'in double quotes.' + + def to_python(self, value): + + records = [] + reader = csv.reader(StringIO(value.strip())) + + # Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional + # "to" field specifying how the related object is being referenced. For example, importing a Device might use a + # `site.slug` header, to indicate the related site is being referenced by its slug. + headers = {} + for header in next(reader): + if '.' in header: + field, to_field = header.split('.', 1) + headers[field] = to_field + else: + headers[header] = None + + # Parse CSV rows into a list of dictionaries mapped from the column headers. + for i, row in enumerate(reader, start=1): + if len(row) != len(headers): + raise forms.ValidationError( + f"Row {i}: Expected {len(headers)} columns but found {len(row)}" + ) + row = [col.strip() for col in row] + record = dict(zip(headers.keys(), row)) + records.append(record) + + return headers, records + + def validate(self, value): + headers, records = value + + # Validate provided column headers + for field, to_field in headers.items(): + if field not in self.fields: + raise forms.ValidationError(f'Unexpected column header "{field}" found.') + if to_field and not hasattr(self.fields[field], 'to_field_name'): + raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots') + if to_field and not hasattr(self.fields[field].queryset.model, to_field): + raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}') + + # Validate required fields + for f in self.required_fields: + if f not in headers: + raise forms.ValidationError(f'Required column header "{f}" not found.') + + return value + + +class CSVChoiceField(forms.ChoiceField): + """ + Invert the provided set of choices to take the human-friendly label as input, and return the database value. + """ + def __init__(self, choices, *args, **kwargs): + super().__init__(choices=choices, *args, **kwargs) + self.choices = [(label, label) for value, label in unpack_grouped_choices(choices)] + self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)} + + def clean(self, value): + value = super().clean(value) + if not value: + return '' + if value not in self.choice_values: + raise forms.ValidationError("Invalid choice: {}".format(value)) + return self.choice_values[value] + + +class CSVModelChoiceField(forms.ModelChoiceField): + """ + Provides additional validation for model choices entered as CSV data. + """ + default_error_messages = { + 'invalid_choice': 'Object not found.', + } + + def to_python(self, value): + try: + return super().to_python(value) + except MultipleObjectsReturned as e: + raise forms.ValidationError( + f'"{value}" is not a unique value for this field; multiple objects were found' + ) + + +class ExpandableNameField(forms.CharField): + """ + A field which allows for numeric range expansion + Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = """ + Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range + are not supported. Examples: +
      +
    • [ge,xe]-0/0/[0-9]
    • +
    • e[0-3][a-d,f]
    • +
    + """ + + def to_python(self, value): + if not value: + return '' + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): + return list(expand_alphanumeric_pattern(value)) + return [value] + + +class ExpandableIPAddressField(forms.CharField): + """ + A field which allows for expansion of IP address ranges + Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Specify a numeric range to create multiple IPs.
    '\ + 'Example: 192.0.2.[1,5,100-254]/24' + + def to_python(self, value): + # Hackish address family detection but it's all we have to work with + if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 4)) + elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 6)) + return [value] + + +class CommentField(forms.CharField): + """ + A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. + """ + widget = forms.Textarea + default_label = '' + # TODO: Port Markdown cheat sheet to internal documentation + default_helptext = ' '\ + ''\ + 'Markdown syntax is supported' + + def __init__(self, *args, **kwargs): + required = kwargs.pop('required', False) + label = kwargs.pop('label', self.default_label) + help_text = kwargs.pop('help_text', self.default_helptext) + super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) + + +class SlugField(forms.SlugField): + """ + Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. + """ + def __init__(self, slug_source='name', *args, **kwargs): + label = kwargs.pop('label', "Slug") + help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") + widget = kwargs.pop('widget', widgets.SlugWidget) + super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) + self.widget.attrs['slug-source'] = slug_source + + +class TagFilterField(forms.MultipleChoiceField): + """ + A filter field for the tags of a model. Only the tags used by a model are displayed. + + :param model: The model of the filter + """ + widget = widgets.StaticSelect2Multiple + + def __init__(self, model, *args, **kwargs): + def get_choices(): + tags = model.tags.annotate( + count=Count('extras_taggeditem_items') + ).order_by('name') + return [ + (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags + ] + + # Choices are fetched each time the form is initialized + super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) + + +class DynamicModelChoiceMixin: + """ + :param display_field: The name of the attribute of an API response object to display in the selection list + :param query_params: A dictionary of additional key/value pairs to attach to the API request + :param null_option: The string used to represent a null selection (if any) + :param disabled_indicator: The name of the field which, if populated, will disable selection of the + choice (optional) + :param brief_mode: Use the "brief" format (?brief=true) when making API requests (default) + """ + filter = django_filters.ModelChoiceFilter + widget = widgets.APISelect + + def __init__(self, display_field='name', query_params=None, null_option=None, disabled_indicator=None, + brief_mode=True, *args, **kwargs): + self.display_field = display_field + self.query_params = query_params or {} + self.null_option = null_option + self.disabled_indicator = disabled_indicator + self.brief_mode = brief_mode + + # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference + # by widget_attrs() + self.to_field_name = kwargs.get('to_field_name') + + super().__init__(*args, **kwargs) + + def widget_attrs(self, widget): + attrs = { + 'display-field': self.display_field, + } + + # Set value-field attribute if the field specifies to_field_name + if self.to_field_name: + attrs['value-field'] = self.to_field_name + + # Set the string used to represent a null option + if self.null_option is not None: + attrs['data-null-option'] = self.null_option + + # Set the disabled indicator, if any + if self.disabled_indicator is not None: + attrs['disabled-indicator'] = self.disabled_indicator + + # Toggle brief mode + if not self.brief_mode: + attrs['data-full'] = 'true' + + # Attach any static query parameters + for key, value in self.query_params.items(): + widget.add_query_param(key, value) + + return attrs + + def get_bound_field(self, form, field_name): + bound_field = BoundField(form, self, field_name) + + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options + # will be populated on-demand via the APISelect widget. + data = bound_field.value() + if data: + field_name = getattr(self, 'to_field_name') or 'pk' + filter = self.filter(field_name=field_name) + try: + self.queryset = filter.filter(self.queryset, data) + except TypeError: + # Catch any error caused by invalid initial data passed from the user + self.queryset = self.queryset.none() + else: + self.queryset = self.queryset.none() + + # Set the data URL on the APISelect widget (if not already set) + widget = bound_field.field.widget + if not widget.attrs.get('data-url'): + app_label = self.queryset.model._meta.app_label + model_name = self.queryset.model._meta.model_name + data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) + widget.attrs['data-url'] = data_url + + return bound_field + + +class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): + """ + Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + """ + pass + + +class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): + """ + A multiple-choice version of DynamicModelChoiceField. + """ + filter = django_filters.ModelMultipleChoiceFilter + widget = widgets.APISelectMultiple + + +class LaxURLField(forms.URLField): + """ + Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names + (e.g. http://myserver/ is valid) + """ + default_validators = [EnhancedURLValidator()] + + +class JSONField(_JSONField): + """ + Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Enter context data in JSON format.' + self.widget.attrs['placeholder'] = '' + + def prepare_value(self, value): + if isinstance(value, InvalidJSONInput): + return value + if value is None: + return '' + return json.dumps(value, sort_keys=True, indent=4) diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py new file mode 100644 index 000000000..bc68f29f6 --- /dev/null +++ b/netbox/utilities/forms/forms.py @@ -0,0 +1,175 @@ +import json +import re + +import yaml +from django import forms + + +__all__ = ( + 'BootstrapMixin', + 'BulkEditForm', + 'BulkRenameForm', + 'ConfirmationForm', + 'CSVModelForm', + 'ImportForm', + 'ReturnURLForm', + 'TableConfigForm', +) + + +class BootstrapMixin(forms.BaseForm): + """ + Add the base Bootstrap CSS classes to form elements. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + exempt_widgets = [ + forms.CheckboxInput, + forms.ClearableFileInput, + forms.FileInput, + forms.RadioSelect + ] + + for field_name, field in self.fields.items(): + if field.widget.__class__ not in exempt_widgets: + css = field.widget.attrs.get('class', '') + field.widget.attrs['class'] = ' '.join([css, 'form-control']).strip() + if field.required and not isinstance(field.widget, forms.FileInput): + field.widget.attrs['required'] = 'required' + if 'placeholder' not in field.widget.attrs: + field.widget.attrs['placeholder'] = field.label + + +class ReturnURLForm(forms.Form): + """ + Provides a hidden return URL field to control where the user is directed after the form is submitted. + """ + return_url = forms.CharField(required=False, widget=forms.HiddenInput()) + + +class ConfirmationForm(BootstrapMixin, ReturnURLForm): + """ + A generic confirmation form. The form is not valid unless the confirm field is checked. + """ + confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) + + +class BulkEditForm(forms.Form): + """ + Base form for editing multiple objects in bulk + """ + def __init__(self, model, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model = model + self.nullable_fields = [] + + # Copy any nullable fields defined in Meta + if hasattr(self.Meta, 'nullable_fields'): + self.nullable_fields = self.Meta.nullable_fields + + +class BulkRenameForm(forms.Form): + """ + An extendable form to be used for renaming objects in bulk. + """ + find = forms.CharField() + replace = forms.CharField() + use_regex = forms.BooleanField( + required=False, + initial=True, + label='Use regular expressions' + ) + + 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: + raise forms.ValidationError({ + 'find': "Invalid regular expression" + }) + + +class CSVModelForm(forms.ModelForm): + """ + ModelForm used for the import of objects in CSV format. + """ + def __init__(self, *args, headers=None, **kwargs): + super().__init__(*args, **kwargs) + + # Modify the model form to accommodate any customized to_field_name properties + if headers: + for field, to_field in headers.items(): + if to_field is not None: + self.fields[field].to_field_name = to_field + + +class ImportForm(BootstrapMixin, forms.Form): + """ + Generic form for creating an object from JSON/YAML data + """ + data = forms.CharField( + widget=forms.Textarea, + help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported." + ) + format = forms.ChoiceField( + choices=( + ('json', 'JSON'), + ('yaml', 'YAML') + ), + initial='yaml' + ) + + def clean(self): + + data = self.cleaned_data['data'] + format = self.cleaned_data['format'] + + # Process JSON/YAML data + if format == 'json': + try: + self.cleaned_data['data'] = json.loads(data) + # Check for multiple JSON objects + if type(self.cleaned_data['data']) is not dict: + raise forms.ValidationError({ + 'data': "Import is limited to one object at a time." + }) + except json.decoder.JSONDecodeError as err: + raise forms.ValidationError({ + 'data': "Invalid JSON data: {}".format(err) + }) + else: + # Check for multiple YAML documents + if '\n---' in data: + raise forms.ValidationError({ + 'data': "Import is limited to one object at a time." + }) + try: + self.cleaned_data['data'] = yaml.load(data, Loader=yaml.SafeLoader) + except yaml.error.YAMLError as err: + raise forms.ValidationError({ + 'data': "Invalid YAML data: {}".format(err) + }) + + +class TableConfigForm(BootstrapMixin, forms.Form): + """ + Form for configuring user's table preferences. + """ + columns = forms.MultipleChoiceField( + choices=[], + widget=forms.SelectMultiple( + attrs={'size': 10} + ), + help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display." + ) + + def __init__(self, table, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Initialize columns field based on table attributes + self.fields['columns'].choices = table.configurable_columns + self.fields['columns'].initial = table.visible_columns diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py new file mode 100644 index 000000000..dc001be1a --- /dev/null +++ b/netbox/utilities/forms/utils.py @@ -0,0 +1,136 @@ +import re + +from django import forms +from django.forms.models import fields_for_model + +from utilities.querysets import RestrictedQuerySet +from .constants import * + +__all__ = ( + 'add_blank_choice', + 'expand_alphanumeric_pattern', + 'expand_ipaddress_pattern', + 'form_from_model', + 'parse_alphanumeric_range', + 'parse_numeric_range', + 'restrict_form_fields', +) + + +def parse_numeric_range(string, base=10): + """ + Expand a numeric range (continuous or not) into a decimal or + hexadecimal list, as specified by the base parameter + '0-3,5' => [0, 1, 2, 3, 5] + '2,8-b,d,f' => [2, 8, 9, a, b, d, f] + """ + values = list() + for dash_range in string.split(','): + try: + begin, end = dash_range.split('-') + except ValueError: + begin, end = dash_range, dash_range + begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1 + values.extend(range(begin, end)) + return list(set(values)) + + +def parse_alphanumeric_range(string): + """ + Expand an alphanumeric range (continuous or not) into a list. + 'a-d,f' => [a, b, c, d, f] + '0-3,a-d' => [0, 1, 2, 3, a, b, c, d] + """ + values = [] + for dash_range in string.split(','): + try: + begin, end = dash_range.split('-') + vals = begin + end + # Break out of loop if there's an invalid pattern to return an error + if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())): + return [] + except ValueError: + begin, end = dash_range, dash_range + if begin.isdigit() and end.isdigit(): + for n in list(range(int(begin), int(end) + 1)): + values.append(n) + else: + # Value-based + if begin == end: + values.append(begin) + # Range-based + else: + # Not a valid range (more than a single character) + if not len(begin) == len(end) == 1: + raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range)) + for n in list(range(ord(begin), ord(end) + 1)): + values.append(chr(n)) + return values + + +def expand_alphanumeric_pattern(string): + """ + Expand an alphabetic pattern into a list of strings. + """ + lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1) + parsed_range = parse_alphanumeric_range(pattern) + for i in parsed_range: + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant): + for string in expand_alphanumeric_pattern(remnant): + yield "{}{}{}".format(lead, i, string) + else: + yield "{}{}{}".format(lead, i, remnant) + + +def expand_ipaddress_pattern(string, family): + """ + Expand an IP address pattern into a list of strings. Examples: + '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24'] + '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64'] + """ + if family not in [4, 6]: + raise Exception("Invalid IP address family: {}".format(family)) + if family == 4: + regex = IP4_EXPANSION_PATTERN + base = 10 + else: + regex = IP6_EXPANSION_PATTERN + base = 16 + lead, pattern, remnant = re.split(regex, string, maxsplit=1) + parsed_range = parse_numeric_range(pattern, base) + for i in parsed_range: + if re.search(regex, remnant): + for string in expand_ipaddress_pattern(remnant, family): + yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), string]) + else: + yield ''.join([lead, format(i, 'x' if family == 6 else 'd'), remnant]) + + +def add_blank_choice(choices): + """ + Add a blank choice to the beginning of a choices list. + """ + return ((None, '---------'),) + tuple(choices) + + +def form_from_model(model, fields): + """ + Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used + for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields + are marked as not required. + """ + form_fields = fields_for_model(model, fields=fields) + for field in form_fields.values(): + field.required = False + + return type('FormFromModel', (forms.Form,), form_fields) + + +def restrict_form_fields(form, user, action='view'): + """ + Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects + as available choices. + """ + for field in form.fields.values(): + if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet): + field.queryset = field.queryset.restrict(user, action) diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py new file mode 100644 index 000000000..9996f7d11 --- /dev/null +++ b/netbox/utilities/forms/widgets.py @@ -0,0 +1,187 @@ +import json + +from django import forms +from django.conf import settings +from django.contrib.postgres.forms import SimpleArrayField + +from utilities.choices import ColorChoices +from .utils import add_blank_choice, parse_numeric_range + +__all__ = ( + 'APISelect', + 'APISelectMultiple', + 'BulkEditNullBooleanSelect', + 'ColorSelect', + 'ContentTypeSelect', + 'DatePicker', + 'DateTimePicker', + 'NumericArrayField', + 'SelectWithDisabled', + 'SelectWithPK', + 'SlugWidget', + 'SmallTextarea', + 'StaticSelect2', + 'StaticSelect2Multiple', + 'TimePicker', +) + + +class SmallTextarea(forms.Textarea): + """ + Subclass used for rendering a smaller textarea element. + """ + pass + + +class SlugWidget(forms.TextInput): + """ + Subclass TextInput and add a slug regeneration button next to the form field. + """ + template_name = 'widgets/sluginput.html' + + +class ColorSelect(forms.Select): + """ + Extends the built-in Select widget to colorize each