Merge branch 'develop-2.9' into issue-4573

This commit is contained in:
Jeremy Stretch 2020-06-24 12:19:33 -04:00 committed by GitHub
commit 064bb97a57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
210 changed files with 9860 additions and 10396 deletions

View File

@ -30,8 +30,7 @@ about: Report a reproducible bug in the current release of NetBox
library such as pynetbox. library such as pynetbox.
--> -->
### Steps to Reproduce ### Steps to Reproduce
1. Disable any installed plugins by commenting out the `PLUGINS` setting in 1.
`configuration.py`.
2. 2.
3. 3.

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
/netbox/static /netbox/static
/venv/ /venv/
/*.sh /*.sh
local_requirements.txt
!upgrade.sh !upgrade.sh
fabfile.py fabfile.py
gunicorn.py gunicorn.py

View File

@ -42,10 +42,6 @@ django-tables2
# https://github.com/alex/django-taggit # https://github.com/alex/django-taggit
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 # A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/ # https://github.com/mfogel/django-timezone-field/
django-timezone-field django-timezone-field

View File

@ -24,7 +24,7 @@ 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 For example, if you only want to display a link for active devices, you could set the link text to
``` ```
{% if obj.status == 1 %}View NMS{% endif %} {% if obj.status == 'active' %}View NMS{% endif %}
``` ```
The link will not appear when viewing a device with any status other than "active." The link will not appear when viewing a device with any status other than "active."

View File

@ -32,3 +32,7 @@ This can be setup by first creating a shared directory and then adding this line
``` ```
environment=prometheus_multiproc_dir=/tmp/prometheus_metrics 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).

View File

@ -33,7 +33,6 @@ Within each report class, we'll create a number of test methods to execute our r
``` ```
from dcim.choices import DeviceStatusChoices from dcim.choices import DeviceStatusChoices
from dcim.constants import CONNECTION_STATUS_PLANNED
from dcim.models import ConsolePort, Device, PowerPort from dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report from extras.reports import Report
@ -53,7 +52,7 @@ class DeviceConnectionsReport(Report):
console_port.device, console_port.device,
"No console connection defined for {}".format(console_port.name) "No console connection defined for {}".format(console_port.name)
) )
elif console_port.connection_status == CONNECTION_STATUS_PLANNED: elif not console_port.connection_status:
self.log_warning( self.log_warning(
console_port.device, console_port.device,
"Console connection for {} marked as planned".format(console_port.name) "Console connection for {} marked as planned".format(console_port.name)
@ -69,7 +68,7 @@ class DeviceConnectionsReport(Report):
for power_port in PowerPort.objects.filter(device=device): for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None: if power_port.connected_endpoint is not None:
connected_ports += 1 connected_ports += 1
if power_port.connection_status == CONNECTION_STATUS_PLANNED: if not power_port.connection_status:
self.log_warning( self.log_warning(
device, device,
"Power connection for {} marked as planned".format(power_port.name) "Power connection for {} marked as planned".format(power_port.name)

View File

@ -0,0 +1,43 @@
# Permissions
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permission model. Object-based permissions allow for the assignment of permissions to an arbitrary subset of objects of a certain type, rather than only by type of object. 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
| Query Filter | Permission Constraints |
| ------------ | --------------------- |
| `filter(status='active')` | `{"status": "active"}` |
| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` |
| `filter(status__in=['planned', 'reserved'])` | `{"status__in": ["planned", "reserved"]}` |
| `filter(name__startswith('Foo')` | `{"name__startswith": "Foo"}` |
| `filter(vid__gte=100, vid__lt=200)` | `{"vid__gte": 100, "vid__lt": 200}` |
## 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 in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints will result in 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 aborted, and the database is left in its original state.

View File

@ -2,18 +2,7 @@
The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API. The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API.
## Tokens {!docs/models/users/token.md!}
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/`.
!!! 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.
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.
## Authenticating to the API ## Authenticating to the API

View File

@ -145,3 +145,18 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
``` ```
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty. 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 <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.

View File

@ -13,6 +13,14 @@ ADMINS = [
--- ---
## ALLOWED_URL_SCHEMES
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).
---
## BANNER_TOP ## BANNER_TOP
## BANNER_BOTTOM ## BANNER_BOTTOM
@ -86,7 +94,12 @@ CORS_ORIGIN_WHITELIST = [
Default: False Default: False
This setting enables debugging. This should be done only during development or troubleshooting. Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users. 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
interface.
!!! warning
Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
--- ---
@ -108,16 +121,20 @@ The file path to NetBox's documentation. This is used when presenting context-se
## EMAIL ## EMAIL
In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting: 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` - Host name or IP address of the email server (use `localhost` if running locally)
* PORT - TCP port to use for the connection (default: 25) * `PORT` - TCP port to use for the connection (default: `25`)
* USERNAME - Username with which to authenticate * `USERNAME` - Username with which to authenticate
* PASSSWORD - Password with which to authenticate * `PASSSWORD` - Password with which to authenticate
* TIMEOUT - Amount of time to wait for a connection (seconds) * `USE_SSL` - Use SSL when connecting to the server (default: `False`). Mutually exclusive with `USE_TLS`.
* FROM_EMAIL - Sender address for emails sent by NetBox * `USE_TLS` - Use TLS when connecting to the server (default: `False`). Mutually exclusive with `USE_SSL`.
* `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`)
Email is sent from NetBox only for critical events. 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): 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):
``` ```
# python ./manage.py nbshell # python ./manage.py nbshell
@ -180,6 +197,16 @@ HTTP_PROXIES = {
--- ---
## INTERNAL_IPS
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
addresses (and [`DEBUG`](#debug) is true).
---
## LOGGING ## 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` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
@ -365,9 +392,12 @@ NetBox can be configured to support remote user authentication by inferring user
## REMOTE_AUTH_BACKEND ## REMOTE_AUTH_BACKEND
Default: `'utilities.auth_backends.RemoteUserBackend'` Default: `'netbox.authentication.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`.) 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 backends may also be provided via other packages.
* `netbox.authentication.RemoteUserBackend`
* `netbox.authentication.LDAPBackend`
--- ---
@ -381,7 +411,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
## REMOTE_AUTH_AUTO_CREATE_USER ## REMOTE_AUTH_AUTO_CREATE_USER
Default: `True` Default: `False`
If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.) If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
@ -397,9 +427,9 @@ The list of groups to assign a new user account when created using remote authen
## REMOTE_AUTH_DEFAULT_PERMISSIONS ## 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`.)
--- ---

View File

@ -4,6 +4,10 @@ Utility views are reusable views that handle common CRUD tasks, such as listing
## Individual Views ## Individual Views
### ObjectView
Retrieve and display a single object.
### ObjectListView ### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered. Generates a paginated table of objects from a given queryset, which may optionally be filtered.

View File

@ -49,7 +49,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache | | HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI | | WSGI service | gunicorn or uWSGI |
| Application | Django/Python | | Application | Django/Python |
| Database | PostgreSQL 9.4+ | | Database | PostgreSQL 9.6+ |
| Task queuing | Redis/django-rq | | Task queuing | Redis/django-rq |
| Live device access | NAPALM | | Live device access | NAPALM |

View File

@ -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). 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 !!! warning
NetBox requires PostgreSQL 9.4 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** 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. 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.
@ -51,7 +51,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
```no-highlight ```no-highlight
# sudo -u postgres psql # sudo -u postgres psql
psql (9.4.5) psql (10.10)
Type "help" for help. Type "help" for help.
postgres=# CREATE DATABASE netbox; postgres=# CREATE DATABASE netbox;

View File

@ -78,7 +78,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
CentOS users may need to create the `netbox` group first. CentOS users may need to create the `netbox` group first.
``` ```
# adduser --system --group netbox # groupadd --system netbox
# adduser --system --gid netbox netbox
# chown --recursive netbox /opt/netbox/netbox/media/ # chown --recursive netbox /opt/netbox/netbox/media/
``` ```

View File

@ -36,7 +36,13 @@ Once installed, add the package to `local_requirements.txt` to ensure it is re-i
## Configuration ## 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 ### General Server Configuration
@ -145,7 +151,8 @@ logfile = "/opt/netbox/logs/django-ldap-debug.log"
my_logger = logging.getLogger('django_auth_ldap') my_logger = logging.getLogger('django_auth_ldap')
my_logger.setLevel(logging.DEBUG) my_logger.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler( handler = logging.handlers.RotatingFileHandler(
logfile, maxBytes=1024 * 500, backupCount=5) logfile, maxBytes=1024 * 500, backupCount=5
)
my_logger.addHandler(handler) my_logger.addHandler(handler)
``` ```

View File

@ -0,0 +1,36 @@
# Object Permissions
Assigning a permission in NetBox entails defining a relationship among several components:
* Object type(s) - One or more types of object in NetBox
* User(s) - One or more users or groups of users
* Actions - The actions that can be performed (view, add, change, and/or delete)
* 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
Some models introduce additional permissions that can be granted to allow other actions. 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.
## Constraints
Constraints are defined as a JSON object 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 constraints defined on a permission are applied with a logic 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, simply create another permission assignment for the same model and user/group.

View File

@ -0,0 +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/`.
!!! 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.
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.

View File

@ -1 +1 @@
version-2.8.md version-2.9.md

View File

@ -1,5 +1,93 @@
# NetBox v2.8 # NetBox v2.8
## v2.8.7 (FUTURE)
### Bug Fixes
* [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified
* [#4772](https://github.com/netbox-community/netbox/issues/4772) - Fix "brief" format for the secrets REST API endpoint
* [#4775](https://github.com/netbox-community/netbox/issues/4775) - Allow selecting an alternate device type when creating component templates
---
## v2.8.6 (2020-06-15)
### Enhancements
* [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI
* [#4717](https://github.com/netbox-community/netbox/issues/4717) - Introduce `ALLOWED_URL_SCHEMES` configuration parameter to mitigate dangerous hyperlinks
* [#4744](https://github.com/netbox-community/netbox/issues/4744) - Hide "IP addresses" tab when viewing a container prefix
* [#4755](https://github.com/netbox-community/netbox/issues/4755) - Enable creation of rack reservations directly from navigation menu
* [#4761](https://github.com/netbox-community/netbox/issues/4761) - Enable tag assignment during bulk creation of IP addresses
### Bug Fixes
* [#4674](https://github.com/netbox-community/netbox/issues/4674) - Fix API definition for available prefix and IP address endpoints
* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret
* [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer
* [#4710](https://github.com/netbox-community/netbox/issues/4710) - Fix merging of form fields among custom scripts
* [#4725](https://github.com/netbox-community/netbox/issues/4725) - Fix "brief" rendering of various REST API endpoints
* [#4736](https://github.com/netbox-community/netbox/issues/4736) - Add cable trace endpoints for pass-through ports
* [#4737](https://github.com/netbox-community/netbox/issues/4737) - Fix display of role labels in virtual machines table
* [#4743](https://github.com/netbox-community/netbox/issues/4743) - Allow users to create "next available" IPs without needing permission to create prefixes
* [#4756](https://github.com/netbox-community/netbox/issues/4756) - Filter parent group by site when creating rack groups
* [#4760](https://github.com/netbox-community/netbox/issues/4760) - Enable power port template assignment when bulk editing power outlet templates
---
## v2.8.5 (2020-05-26)
**Note:** The minimum required version of PostgreSQL is now 9.6.
### Enhancements
* [#4650](https://github.com/netbox-community/netbox/issues/4650) - Expose `INTERNAL_IPS` configuration parameter
* [#4651](https://github.com/netbox-community/netbox/issues/4651) - Add `csrf_token` context for plugin templates
* [#4652](https://github.com/netbox-community/netbox/issues/4652) - Add permissions context for plugin templates
* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types
* [#4672](https://github.com/netbox-community/netbox/issues/4672) - Set default color for rack and devices roles
### Bug Fixes
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
* [#4525](https://github.com/netbox-community/netbox/issues/4525) - Allow passing initial data to custom script MultiObjectVar
* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent
* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices
* [#4649](https://github.com/netbox-community/netbox/issues/4649) - Fix interface assignment for bulk-imported IP addresses
* [#4676](https://github.com/netbox-community/netbox/issues/4676) - Set default value of `REMOTE_AUTH_AUTO_CREATE_USER` as `False` in docs
* [#4684](https://github.com/netbox-community/netbox/issues/4684) - Respect `comments` field when importing device type in YAML/JSON format
---
## v2.8.4 (2020-05-13)
### Enhancements
* [#4632](https://github.com/netbox-community/netbox/issues/4632) - Extend email configuration parameters to support SSL/TLS
### Bug Fixes
* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
* [#4613](https://github.com/netbox-community/netbox/issues/4613) - Fix tag assignment on config contexts (regression from #4527)
* [#4617](https://github.com/netbox-community/netbox/issues/4617) - Restore IP prefix depth notation in list view
* [#4629](https://github.com/netbox-community/netbox/issues/4629) - Replicate assigned interface when cloning IP addresses
* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0
* [#4634](https://github.com/netbox-community/netbox/issues/4634) - Inventory Item List view exception caused by incorrect accessor definition
---
## v2.8.3 (2020-05-06)
### Bug Fixes
* [#4593](https://github.com/netbox-community/netbox/issues/4593) - Fix AttributeError exception when viewing object lists as a non-authenticated user
---
## v2.8.2 (2020-05-06) ## v2.8.2 (2020-05-06)
### Enhancements ### Enhancements

View File

@ -1,7 +1,43 @@
# Netbox v2.9 # NetBox v2.9
## v2.9.0 (FUTURE) ## v2.9.0 (FUTURE)
### 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 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 grant the permission only for devices assigned to a tenant belonging to the "Customers" group.
### Enhancements ### Enhancements
* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object
* [#4573](https://github.com/netbox-community/netbox/issues/4573) - Support plugins as a delivery mechanism for reports and custom scripts * [#4573](https://github.com/netbox-community/netbox/issues/4573) - Support plugins as a delivery mechanism for reports and custom scripts
* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components
* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
### 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
* The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed.
* 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"}
]
```
* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name.
* A `label` field has been added to all device components and component templates.
### Other Changes
* 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/`).

View File

@ -58,6 +58,7 @@ nav:
- Using Plugins: 'plugins/index.md' - Using Plugins: 'plugins/index.md'
- Developing Plugins: 'plugins/development.md' - Developing Plugins: 'plugins/development.md'
- Administration: - Administration:
- Permissions: 'administration/permissions.md'
- Replicating NetBox: 'administration/replicating-netbox.md' - Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md' - NetBox Shell: 'administration/netbox-shell.md'
- API: - API:

View File

@ -1,11 +1,11 @@
from rest_framework import serializers from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.choices import CircuitStatusChoices from circuits.choices import CircuitStatusChoices
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
from dcim.api.serializers import ConnectedEndpointSerializer from dcim.api.serializers import ConnectedEndpointSerializer
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from .nested_serializers import * from .nested_serializers import *
@ -15,8 +15,7 @@ from .nested_serializers import *
# Providers # Providers
# #
class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
tags = TagListSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True) circuit_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
@ -49,14 +48,13 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id'] fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
provider = NestedProviderSerializer() provider = NestedProviderSerializer()
status = ChoiceField(choices=CircuitStatusChoices, required=False) status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = NestedCircuitTypeSerializer() type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
termination_a = CircuitCircuitTerminationSerializer(read_only=True) termination_a = CircuitCircuitTerminationSerializer(read_only=True)
termination_z = CircuitCircuitTerminationSerializer(read_only=True) termination_z = CircuitCircuitTerminationSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Circuit model = Circuit

View File

@ -24,13 +24,13 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilte
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='circuits__terminations__site__region', field_name='circuits__terminations__site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='circuits__terminations__site__region', field_name='circuits__terminations__site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -38,12 +38,12 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilte
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site', field_name='circuits__terminations__site',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site', label='Site',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site__slug', field_name='circuits__terminations__site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
@ -78,22 +78,22 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr
label='Search', label='Search',
) )
provider_id = django_filters.ModelMultipleChoiceFilter( provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(), queryset=Provider.objects.unrestricted(),
label='Provider (ID)', label='Provider (ID)',
) )
provider = django_filters.ModelMultipleChoiceFilter( provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug', field_name='provider__slug',
queryset=Provider.objects.all(), queryset=Provider.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Provider (slug)', label='Provider (slug)',
) )
type_id = django_filters.ModelMultipleChoiceFilter( type_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.unrestricted(),
label='Circuit type (ID)', label='Circuit type (ID)',
) )
type = django_filters.ModelMultipleChoiceFilter( type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug', field_name='type__slug',
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Circuit type (slug)', label='Circuit type (slug)',
) )
@ -103,23 +103,23 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, Cr
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site', field_name='terminations__site',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site__slug', field_name='terminations__site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='terminations__site__region', field_name='terminations__site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='terminations__site__region', field_name='terminations__site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -150,16 +150,16 @@ class CircuitTerminationFilterSet(BaseFilterSet):
label='Search', label='Search',
) )
circuit_id = django_filters.ModelMultipleChoiceFilter( circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=Circuit.objects.all(), queryset=Circuit.objects.unrestricted(),
label='Circuit', label='Circuit',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )

View File

@ -1,10 +1,10 @@
from django import forms from django import forms
from taggit.forms import TagField
from dcim.models import Region, Site from dcim.models import Region, Site
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
) )
from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
@ -23,7 +23,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
class ProviderForm(BootstrapMixin, CustomFieldModelForm): class ProviderForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -165,7 +166,8 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=CircuitType.objects.all() queryset=CircuitType.objects.all()
) )
comments = CommentField() comments = CommentField()
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )

View File

@ -8,6 +8,7 @@ from dcim.fields import ASNField
from dcim.models import CableTermination from dcim.models import CableTermination
from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.querysets import RestrictedQuerySet
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object from utilities.utils import serialize_object
from .choices import * from .choices import *
@ -66,9 +67,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
] ]
@ -115,6 +117,8 @@ class CircuitType(ChangeLoggedModel):
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description'] csv_headers = ['name', 'slug', 'description']
class Meta: class Meta:
@ -300,6 +304,8 @@ class CircuitTermination(CableTermination):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
ordering = ['circuit', 'term_side'] ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side']

View File

@ -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): def annotate_sites(self):
""" """

View File

@ -1,443 +1,189 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from rest_framework import status
from circuits.choices import * from circuits.choices import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Site from dcim.models import Site
from extras.models import Graph from extras.models import Graph
from utilities.testing import APITestCase from utilities.testing import APITestCase, APIViewTestCases
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):
url = reverse('circuits-api:api-root') url = reverse('circuits-api:api-root')
response = self.client.get('{}?format=api'.format(url), **self.header) response = self.client.get('{}?format=api'.format(url), **self.header)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class ProviderTest(APITestCase): class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Provider 4',
'slug': 'provider-4',
},
{
'name': 'Provider 5',
'slug': 'provider-5',
},
{
'name': 'Provider 6',
'slug': 'provider-6',
},
]
def setUp(self): @classmethod
def setUpTestData(cls):
super().setUp() providers = (
Provider(name='Provider 1', slug='provider-1'),
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') Provider(name='Provider 2', slug='provider-2'),
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') Provider(name='Provider 3', slug='provider-3'),
self.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3') )
Provider.objects.bulk_create(providers)
def test_get_provider(self):
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.provider1.name)
def test_get_provider_graphs(self): def test_get_provider_graphs(self):
"""
Test retrieval of Graphs assigned to Providers.
"""
provider = self.model.objects.first()
ct = ContentType.objects.get(app_label='circuits', model='provider')
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
provider_ct = ContentType.objects.get(app_label='circuits', model='provider') self.add_permissions('circuits.view_provider')
self.graph1 = Graph.objects.create( url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
type=provider_ct,
name='Test Graph 1',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=provider_ct,
name='Test Graph 2',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=provider_ct,
name='Test Graph 3',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'
)
url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.pk})
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3) self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=test-provider-1&foo=1') self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1')
def test_list_providers(self):
url = reverse('circuits-api:provider-list') class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
response = self.client.get(url, **self.header) model = CircuitType
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
self.assertEqual(response.data['count'], 3) create_data = (
{
def test_list_providers_brief(self): 'name': 'Circuit Type 4',
'slug': 'circuit-type-4',
url = reverse('circuits-api:provider-list') },
response = self.client.get('{}?brief=1'.format(url), **self.header) {
'name': 'Circuit Type 5',
self.assertEqual( 'slug': 'circuit-type-5',
sorted(response.data['results'][0]), },
['circuit_count', 'id', 'name', 'slug', 'url'] {
'name': 'Circuit Type 6',
'slug': 'circuit-type-6',
},
) )
def test_create_provider(self): @classmethod
def setUpTestData(cls):
data = { circuit_types = (
'name': 'Test Provider 4', CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
'slug': 'test-provider-4', CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
} CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
)
CircuitType.objects.bulk_create(circuit_types)
url = reverse('circuits-api:provider-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) class CircuitTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(Provider.objects.count(), 4) model = Circuit
provider4 = Provider.objects.get(pk=response.data['id']) brief_fields = ['cid', 'id', 'url']
self.assertEqual(provider4.name, data['name'])
self.assertEqual(provider4.slug, data['slug'])
def test_create_provider_bulk(self): @classmethod
def setUpTestData(cls):
data = [ providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
)
Provider.objects.bulk_create(providers)
circuit_types = (
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
)
CircuitType.objects.bulk_create(circuit_types)
circuits = (
Circuit(cid='Circuit 1', provider=providers[0], type=circuit_types[0]),
Circuit(cid='Circuit 2', provider=providers[0], type=circuit_types[0]),
Circuit(cid='Circuit 3', provider=providers[0], type=circuit_types[0]),
)
Circuit.objects.bulk_create(circuits)
cls.create_data = [
{ {
'name': 'Test Provider 4', 'cid': 'Circuit 4',
'slug': 'test-provider-4', 'provider': providers[1].pk,
'type': circuit_types[1].pk,
}, },
{ {
'name': 'Test Provider 5', 'cid': 'Circuit 5',
'slug': 'test-provider-5', 'provider': providers[1].pk,
'type': circuit_types[1].pk,
}, },
{ {
'name': 'Test Provider 6', 'cid': 'Circuit 6',
'slug': 'test-provider-6', 'provider': providers[1].pk,
'type': circuit_types[1].pk,
}, },
] ]
url = reverse('circuits-api:provider-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(Provider.objects.count(), 6) model = CircuitTermination
self.assertEqual(response.data[0]['name'], data[0]['name']) brief_fields = ['circuit', 'id', 'term_side', 'url']
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_provider(self): @classmethod
def setUpTestData(cls):
SIDE_A = CircuitTerminationSideChoices.SIDE_A
SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
data = { sites = (
'name': 'Test Provider X', Site(name='Site 1', slug='site-1'),
'slug': 'test-provider-x', Site(name='Site 2', slug='site-2'),
}
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Provider.objects.count(), 3)
provider1 = Provider.objects.get(pk=response.data['id'])
self.assertEqual(provider1.name, data['name'])
self.assertEqual(provider1.slug, data['slug'])
def test_delete_provider(self):
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Provider.objects.count(), 2)
class CircuitTypeTest(APITestCase):
def setUp(self):
super().setUp()
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
self.circuittype3 = CircuitType.objects.create(name='Test Circuit Type 3', slug='test-circuit-type-3')
def test_get_circuittype(self):
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.circuittype1.name)
def test_list_circuittypes(self):
url = reverse('circuits-api:circuittype-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_circuittypes_brief(self):
url = reverse('circuits-api:circuittype-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['circuit_count', 'id', 'name', 'slug', 'url']
) )
Site.objects.bulk_create(sites)
def test_create_circuittype(self): provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
data = { circuits = (
'name': 'Test Circuit Type 4', Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
'slug': 'test-circuit-type-4', Circuit(cid='Circuit 2', provider=provider, type=circuit_type),
} Circuit(cid='Circuit 3', provider=provider, type=circuit_type),
url = reverse('circuits-api:circuittype-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitType.objects.count(), 4)
circuittype4 = CircuitType.objects.get(pk=response.data['id'])
self.assertEqual(circuittype4.name, data['name'])
self.assertEqual(circuittype4.slug, data['slug'])
def test_update_circuittype(self):
data = {
'name': 'Test Circuit Type X',
'slug': 'test-circuit-type-x',
}
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitType.objects.count(), 3)
circuittype1 = CircuitType.objects.get(pk=response.data['id'])
self.assertEqual(circuittype1.name, data['name'])
self.assertEqual(circuittype1.slug, data['slug'])
def test_delete_circuittype(self):
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(CircuitType.objects.count(), 2)
class CircuitTest(APITestCase):
def setUp(self):
super().setUp()
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=self.provider1, type=self.circuittype1)
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=self.provider1, type=self.circuittype1)
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=self.provider1, type=self.circuittype1)
def test_get_circuit(self):
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['cid'], self.circuit1.cid)
def test_list_circuits(self):
url = reverse('circuits-api:circuit-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_circuits_brief(self):
url = reverse('circuits-api:circuit-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cid', 'id', 'url']
) )
Circuit.objects.bulk_create(circuits)
def test_create_circuit(self): circuit_terminations = (
CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A),
CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z),
CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A),
CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
data = { cls.create_data = [
'cid': 'TEST0004',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
}
url = reverse('circuits-api:circuit-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Circuit.objects.count(), 4)
circuit4 = Circuit.objects.get(pk=response.data['id'])
self.assertEqual(circuit4.cid, data['cid'])
self.assertEqual(circuit4.provider_id, data['provider'])
self.assertEqual(circuit4.type_id, data['type'])
def test_create_circuit_bulk(self):
data = [
{ {
'cid': 'TEST0004', 'circuit': circuits[2].pk,
'provider': self.provider1.pk, 'term_side': SIDE_A,
'type': self.circuittype1.pk, 'site': sites[1].pk,
'status': CircuitStatusChoices.STATUS_ACTIVE, 'port_speed': 200000,
}, },
{ {
'cid': 'TEST0005', 'circuit': circuits[2].pk,
'provider': self.provider1.pk, 'term_side': SIDE_Z,
'type': self.circuittype1.pk, 'site': sites[1].pk,
'status': CircuitStatusChoices.STATUS_ACTIVE, 'port_speed': 200000,
},
{
'cid': 'TEST0006',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
}, },
] ]
url = reverse('circuits-api:circuit-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Circuit.objects.count(), 6)
self.assertEqual(response.data[0]['cid'], data[0]['cid'])
self.assertEqual(response.data[1]['cid'], data[1]['cid'])
self.assertEqual(response.data[2]['cid'], data[2]['cid'])
def test_update_circuit(self):
data = {
'cid': 'TEST000X',
'provider': self.provider2.pk,
'type': self.circuittype2.pk,
}
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Circuit.objects.count(), 3)
circuit1 = Circuit.objects.get(pk=response.data['id'])
self.assertEqual(circuit1.cid, data['cid'])
self.assertEqual(circuit1.provider_id, data['provider'])
self.assertEqual(circuit1.type_id, data['type'])
def test_delete_circuit(self):
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Circuit.objects.count(), 2)
class CircuitTerminationTest(APITestCase):
def setUp(self):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
provider = Provider.objects.create(name='Test Provider', slug='test-provider')
circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype)
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
self.circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit1,
term_side=CircuitTerminationSideChoices.SIDE_A,
site=self.site1,
port_speed=1000000
)
self.circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit1,
term_side=CircuitTerminationSideChoices.SIDE_Z,
site=self.site2,
port_speed=1000000
)
self.circuittermination3 = CircuitTermination.objects.create(
circuit=self.circuit2,
term_side=CircuitTerminationSideChoices.SIDE_A,
site=self.site1,
port_speed=1000000
)
self.circuittermination4 = CircuitTermination.objects.create(
circuit=self.circuit2,
term_side=CircuitTerminationSideChoices.SIDE_Z,
site=self.site2,
port_speed=1000000
)
def test_get_circuittermination(self):
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['id'], self.circuittermination1.pk)
def test_list_circuitterminations(self):
url = reverse('circuits-api:circuittermination-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 4)
def test_create_circuittermination(self):
data = {
'circuit': self.circuit3.pk,
'term_side': CircuitTerminationSideChoices.SIDE_A,
'site': self.site1.pk,
'port_speed': 1000000,
}
url = reverse('circuits-api:circuittermination-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitTermination.objects.count(), 5)
circuittermination4 = CircuitTermination.objects.get(pk=response.data['id'])
self.assertEqual(circuittermination4.circuit_id, data['circuit'])
self.assertEqual(circuittermination4.term_side, data['term_side'])
self.assertEqual(circuittermination4.site_id, data['site'])
self.assertEqual(circuittermination4.port_speed, data['port_speed'])
def test_update_circuittermination(self):
circuittermination5 = CircuitTermination.objects.create(
circuit=self.circuit3,
term_side=CircuitTerminationSideChoices.SIDE_A,
site=self.site1,
port_speed=1000000
)
data = {
'circuit': self.circuit3.pk,
'term_side': CircuitTerminationSideChoices.SIDE_Z,
'site': self.site2.pk,
'port_speed': 1000000,
}
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitTermination.objects.count(), 5)
circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
self.assertEqual(circuittermination1.term_side, data['term_side'])
self.assertEqual(circuittermination1.site_id, data['site'])
self.assertEqual(circuittermination1.port_speed, data['port_speed'])
def test_delete_circuittermination(self):
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(CircuitTermination.objects.count(), 3)

View File

@ -17,6 +17,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Provider(name='Provider 3', slug='provider-3', asn=65003), Provider(name='Provider 3', slug='provider-3', asn=65003),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Provider X', 'name': 'Provider X',
'slug': 'provider-x', 'slug': 'provider-x',
@ -26,7 +28,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'noc_contact': 'noc@example.com', 'noc_contact': 'noc@example.com',
'admin_contact': 'admin@example.com', 'admin_contact': 'admin@example.com',
'comments': 'Another provider', 'comments': 'Another provider',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -96,6 +98,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]), Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'cid': 'Circuit X', 'cid': 'Circuit X',
'provider': providers[1].pk, 'provider': providers[1].pk,
@ -106,7 +110,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 1000, 'commit_rate': 1000,
'description': 'A new circuit', 'description': 'A new circuit',
'comments': 'Some comments', 'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -124,5 +128,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 2000, 'commit_rate': 2000,
'description': 'New description', 'description': 'New description',
'comments': 'New comments', 'comments': 'New comments',
} }

View File

@ -10,7 +10,7 @@ urlpatterns = [
# Providers # Providers
path('providers/', views.ProviderListView.as_view(), name='provider_list'), 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/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
@ -21,7 +21,7 @@ urlpatterns = [
# Circuit types # Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), 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/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
@ -29,7 +29,7 @@ urlpatterns = [
# Circuits # Circuits
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), 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/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
@ -37,11 +37,10 @@ urlpatterns = [
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path('circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
# Circuit terminations # Circuit terminations
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),

View File

@ -1,18 +1,15 @@
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction from django.db import transaction
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from extras.models import Graph from extras.models import Graph
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from . import filters, forms, tables from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices from .choices import CircuitTerminationSideChoices
@ -23,21 +20,20 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers # Providers
# #
class ProviderListView(PermissionRequiredMixin, ObjectListView): class ProviderListView(ObjectListView):
permission_required = 'circuits.view_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits')) queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm filterset_form = forms.ProviderFilterForm
table = tables.ProviderTable table = tables.ProviderTable
class ProviderView(PermissionRequiredMixin, View): class ProviderView(ObjectView):
permission_required = 'circuits.view_provider' queryset = Provider.objects.all()
def get(self, request, slug): def get(self, request, slug):
provider = get_object_or_404(Provider, slug=slug) provider = get_object_or_404(self.queryset, slug=slug)
circuits = Circuit.objects.filter( circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=provider provider=provider
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
@ -60,33 +56,26 @@ class ProviderView(PermissionRequiredMixin, View):
}) })
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): class ProviderEditView(ObjectEditView):
permission_required = 'circuits.add_provider'
queryset = Provider.objects.all() queryset = Provider.objects.all()
model_form = forms.ProviderForm model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html' template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderEditView(ProviderCreateView): class ProviderDeleteView(ObjectDeleteView):
permission_required = 'circuits.change_provider'
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
queryset = Provider.objects.all() queryset = Provider.objects.all()
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): class ProviderBulkImportView(BulkImportView):
permission_required = 'circuits.add_provider' queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm model_form = forms.ProviderCSVForm
table = tables.ProviderTable table = tables.ProviderTable
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): class ProviderBulkEditView(BulkEditView):
permission_required = 'circuits.change_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits')) queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
table = tables.ProviderTable table = tables.ProviderTable
@ -94,8 +83,7 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ProviderBulkDeleteView(BulkDeleteView):
permission_required = 'circuits.delete_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits')) queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet filterset = filters.ProviderFilterSet
table = tables.ProviderTable table = tables.ProviderTable
@ -106,32 +94,25 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Circuit Types # Circuit Types
# #
class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): class CircuitTypeListView(ObjectListView):
permission_required = 'circuits.view_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): class CircuitTypeEditView(ObjectEditView):
permission_required = 'circuits.add_circuittype'
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm model_form = forms.CircuitTypeForm
default_return_url = 'circuits:circuittype_list' default_return_url = 'circuits:circuittype_list'
class CircuitTypeEditView(CircuitTypeCreateView): class CircuitTypeBulkImportView(BulkImportView):
permission_required = 'circuits.change_circuittype' queryset = CircuitType.objects.all()
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuittype'
model_form = forms.CircuitTypeCSVForm model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list' default_return_url = 'circuits:circuittype_list'
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class CircuitTypeBulkDeleteView(BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list' default_return_url = 'circuits:circuittype_list'
@ -141,8 +122,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Circuits # Circuits
# #
class CircuitListView(PermissionRequiredMixin, ObjectListView): class CircuitListView(ObjectListView):
permission_required = 'circuits.view_circuit'
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations__site' 'provider', 'type', 'tenant', 'terminations__site'
@ -152,22 +132,27 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView):
table = tables.CircuitTable table = tables.CircuitTable
class CircuitView(PermissionRequiredMixin, View): class CircuitView(ObjectView):
permission_required = 'circuits.view_circuit' queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
def get(self, request, pk): 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.restrict(request.user, 'view').prefetch_related(
termination_a = CircuitTermination.objects.prefetch_related(
'site__region', 'connected_endpoint__device' 'site__region', 'connected_endpoint__device'
).filter( ).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
).first() ).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' 'site__region', 'connected_endpoint__device'
).filter( ).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
).first() ).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', { return render(request, 'circuits/circuit.html', {
'circuit': circuit, 'circuit': circuit,
@ -176,33 +161,26 @@ class CircuitView(PermissionRequiredMixin, View):
}) })
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): class CircuitEditView(ObjectEditView):
permission_required = 'circuits.add_circuit'
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
model_form = forms.CircuitForm model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html' template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitEditView(CircuitCreateView): class CircuitDeleteView(ObjectDeleteView):
permission_required = 'circuits.change_circuit'
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitBulkImportView(BulkImportView):
permission_required = 'circuits.add_circuit' queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm model_form = forms.CircuitCSVForm
table = tables.CircuitTable table = tables.CircuitTable
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): class CircuitBulkEditView(BulkEditView):
permission_required = 'circuits.change_circuit'
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filterset = filters.CircuitFilterSet filterset = filters.CircuitFilterSet
table = tables.CircuitTable table = tables.CircuitTable
@ -210,33 +188,54 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class CircuitBulkDeleteView(BulkDeleteView):
permission_required = 'circuits.delete_circuit'
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site') queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filterset = filters.CircuitFilterSet filterset = filters.CircuitFilterSet
table = tables.CircuitTable table = tables.CircuitTable
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
@permission_required('circuits.change_circuittermination') class CircuitSwapTerminations(ObjectEditView):
def circuit_terminations_swap(request, pk): """
Swap the A and Z terminations of a circuit.
"""
queryset = Circuit.objects.all()
def get(self, request, pk):
circuit = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm()
# 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():
circuit = get_object_or_404(Circuit, pk=pk)
termination_a = CircuitTermination.objects.filter( termination_a = CircuitTermination.objects.filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
).first() ).first()
termination_z = CircuitTermination.objects.filter( termination_z = CircuitTermination.objects.filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
).first() ).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)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
if termination_a and termination_z: if termination_a and termination_z:
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
print('swapping')
with transaction.atomic(): with transaction.atomic():
termination_a.term_side = '_' termination_a.term_side = '_'
termination_a.save() termination_a.save()
@ -250,16 +249,14 @@ def circuit_terminations_swap(request, pk):
else: else:
termination_z.term_side = 'A' termination_z.term_side = 'A'
termination_z.save() termination_z.save()
messages.success(request, "Swapped terminations for circuit {}.".format(circuit)) messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk) return redirect('circuits:circuit', pk=circuit.pk)
else:
form = ConfirmationForm()
return render(request, 'circuits/circuit_terminations_swap.html', { return render(request, 'circuits/circuit_terminations_swap.html', {
'circuit': circuit, 'circuit': circuit,
'termination_a': termination_a, 'termination_a': circuit.termination_a,
'termination_z': termination_z, 'termination_z': circuit.termination_z,
'form': form, 'form': form,
'panel_class': 'default', 'panel_class': 'default',
'button_class': 'primary', 'button_class': 'primary',
@ -271,8 +268,7 @@ def circuit_terminations_swap(request, pk):
# Circuit terminations # Circuit terminations
# #
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): class CircuitTerminationEditView(ObjectEditView):
permission_required = 'circuits.add_circuittermination'
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationForm model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html' template_name = 'circuits/circuittermination_edit.html'
@ -286,10 +282,5 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
return obj.circuit.get_absolute_url() return obj.circuit.get_absolute_url()
class CircuitTerminationEditView(CircuitTerminationCreateView): class CircuitTerminationDeleteView(ObjectDeleteView):
permission_required = 'circuits.change_circuittermination'
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()

View File

@ -1,32 +1,35 @@
from rest_framework import serializers from rest_framework import serializers
from dcim.constants import CONNECTION_STATUS_CHOICES from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.models import ( from dcim import models
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
)
from utilities.api import ChoiceField, WritableNestedSerializer from utilities.api import ChoiceField, WritableNestedSerializer
__all__ = [ __all__ = [
'NestedCableSerializer', 'NestedCableSerializer',
'NestedConsolePortSerializer', 'NestedConsolePortSerializer',
'NestedConsolePortTemplateSerializer',
'NestedConsoleServerPortSerializer', 'NestedConsoleServerPortSerializer',
'NestedConsoleServerPortTemplateSerializer',
'NestedDeviceBaySerializer', 'NestedDeviceBaySerializer',
'NestedDeviceBayTemplateSerializer',
'NestedDeviceRoleSerializer', 'NestedDeviceRoleSerializer',
'NestedDeviceSerializer', 'NestedDeviceSerializer',
'NestedDeviceTypeSerializer', 'NestedDeviceTypeSerializer',
'NestedFrontPortSerializer', 'NestedFrontPortSerializer',
'NestedFrontPortTemplateSerializer', 'NestedFrontPortTemplateSerializer',
'NestedInterfaceSerializer', 'NestedInterfaceSerializer',
'NestedInterfaceTemplateSerializer',
'NestedInventoryItemSerializer',
'NestedManufacturerSerializer', 'NestedManufacturerSerializer',
'NestedPlatformSerializer', 'NestedPlatformSerializer',
'NestedPowerFeedSerializer', 'NestedPowerFeedSerializer',
'NestedPowerOutletSerializer', 'NestedPowerOutletSerializer',
'NestedPowerOutletTemplateSerializer',
'NestedPowerPanelSerializer', 'NestedPowerPanelSerializer',
'NestedPowerPortSerializer', 'NestedPowerPortSerializer',
'NestedPowerPortTemplateSerializer', 'NestedPowerPortTemplateSerializer',
'NestedRackGroupSerializer', 'NestedRackGroupSerializer',
'NestedRackReservationSerializer',
'NestedRackRoleSerializer', 'NestedRackRoleSerializer',
'NestedRackSerializer', 'NestedRackSerializer',
'NestedRearPortSerializer', 'NestedRearPortSerializer',
@ -46,7 +49,7 @@ class NestedRegionSerializer(WritableNestedSerializer):
site_count = serializers.IntegerField(read_only=True) site_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Region model = models.Region
fields = ['id', 'url', 'name', 'slug', 'site_count'] fields = ['id', 'url', 'name', 'slug', 'site_count']
@ -54,7 +57,7 @@ class NestedSiteSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
class Meta: class Meta:
model = Site model = models.Site
fields = ['id', 'url', 'name', 'slug'] fields = ['id', 'url', 'name', 'slug']
@ -67,7 +70,7 @@ class NestedRackGroupSerializer(WritableNestedSerializer):
rack_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = RackGroup model = models.RackGroup
fields = ['id', 'url', 'name', 'slug', 'rack_count'] fields = ['id', 'url', 'name', 'slug', 'rack_count']
@ -76,7 +79,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
rack_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = RackRole model = models.RackRole
fields = ['id', 'url', 'name', 'slug', 'rack_count'] fields = ['id', 'url', 'name', 'slug', 'rack_count']
@ -85,10 +88,22 @@ class NestedRackSerializer(WritableNestedSerializer):
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Rack model = models.Rack
fields = ['id', 'url', 'name', 'display_name', 'device_count'] fields = ['id', 'url', 'name', 'display_name', 'device_count']
class NestedRackReservationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
user = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.RackReservation
fields = ['id', 'url', 'user', 'units']
def get_user(self, obj):
return obj.user.username
# #
# Device types # Device types
# #
@ -98,7 +113,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
devicetype_count = serializers.IntegerField(read_only=True) devicetype_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Manufacturer model = models.Manufacturer
fields = ['id', 'url', 'name', 'slug', 'devicetype_count'] fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
@ -108,15 +123,47 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = DeviceType model = models.DeviceType
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
class Meta:
model = models.ConsolePortTemplate
fields = ['id', 'url', 'name']
class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
class Meta:
model = models.ConsoleServerPortTemplate
fields = ['id', 'url', 'name']
class NestedPowerPortTemplateSerializer(WritableNestedSerializer): class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
class Meta: class Meta:
model = PowerPortTemplate model = models.PowerPortTemplate
fields = ['id', 'url', 'name']
class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
class Meta:
model = models.PowerOutletTemplate
fields = ['id', 'url', 'name']
class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
class Meta:
model = models.InterfaceTemplate
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']
@ -124,7 +171,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
class Meta: class Meta:
model = RearPortTemplate model = models.RearPortTemplate
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']
@ -132,7 +179,15 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
class Meta: class Meta:
model = FrontPortTemplate model = models.FrontPortTemplate
fields = ['id', 'url', 'name']
class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
class Meta:
model = models.DeviceBayTemplate
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']
@ -146,7 +201,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = DeviceRole model = models.DeviceRole
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
@ -156,7 +211,7 @@ class NestedPlatformSerializer(WritableNestedSerializer):
virtualmachine_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Platform model = models.Platform
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
@ -164,7 +219,7 @@ class NestedDeviceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
class Meta: class Meta:
model = Device model = models.Device
fields = ['id', 'url', 'name', 'display_name'] fields = ['id', 'url', 'name', 'display_name']
@ -174,7 +229,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = ConsoleServerPort model = models.ConsoleServerPort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@ -184,7 +239,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = ConsolePort model = models.ConsolePort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@ -194,7 +249,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = PowerOutlet model = models.PowerOutlet
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@ -204,7 +259,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = PowerPort model = models.PowerPort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@ -214,7 +269,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta: class Meta:
model = Interface model = models.Interface
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@ -223,7 +278,7 @@ class NestedRearPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
class Meta: class Meta:
model = RearPort model = models.RearPort
fields = ['id', 'url', 'device', 'name', 'cable'] fields = ['id', 'url', 'device', 'name', 'cable']
@ -232,7 +287,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
class Meta: class Meta:
model = FrontPort model = models.FrontPort
fields = ['id', 'url', 'device', 'name', 'cable'] fields = ['id', 'url', 'device', 'name', 'cable']
@ -241,7 +296,16 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
class Meta: class Meta:
model = DeviceBay model = models.DeviceBay
fields = ['id', 'url', 'device', 'name']
class NestedInventoryItemSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = models.InventoryItem
fields = ['id', 'url', 'device', 'name'] fields = ['id', 'url', 'device', 'name']
@ -253,7 +317,7 @@ class NestedCableSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta: class Meta:
model = Cable model = models.Cable
fields = ['id', 'url', 'label'] fields = ['id', 'url', 'label']
@ -267,7 +331,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
member_count = serializers.IntegerField(read_only=True) member_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VirtualChassis model = models.VirtualChassis
fields = ['id', 'url', 'master', 'member_count'] fields = ['id', 'url', 'master', 'member_count']
@ -280,7 +344,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer):
powerfeed_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = PowerPanel model = models.PowerPanel
fields = ['id', 'url', 'name', 'powerfeed_count'] fields = ['id', 'url', 'name', 'powerfeed_count']
@ -288,5 +352,5 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
class Meta: class Meta:
model = PowerFeed model = models.PowerFeed
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']

View File

@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
@ -14,6 +13,7 @@ from dcim.models import (
VirtualChassis, VirtualChassis,
) )
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN from ipam.models import VLAN
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
@ -67,12 +67,11 @@ class RegionSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count'] fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count']
class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=SiteStatusChoices, required=False) status = ChoiceField(choices=SiteStatusChoices, required=False)
region = NestedRegionSerializer(required=False, allow_null=True) region = NestedRegionSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False) time_zone = TimeZoneField(required=False)
tags = TagListSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True) circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
@ -112,7 +111,7 @@ class RackRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count'] fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count']
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None) group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
@ -121,7 +120,6 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
width = ChoiceField(choices=RackWidthChoices, required=False) width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True)
@ -161,14 +159,14 @@ class RackUnitSerializer(serializers.Serializer):
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
class RackReservationSerializer(ValidatedModelSerializer): class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
rack = NestedRackSerializer() rack = NestedRackSerializer()
user = NestedUserSerializer() user = NestedUserSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags']
class RackElevationDetailFilterSerializer(serializers.Serializer): class RackElevationDetailFilterSerializer(serializers.Serializer):
@ -223,10 +221,9 @@ class ManufacturerSerializer(ValidatedModelSerializer):
] ]
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
@ -248,7 +245,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = ConsolePortTemplate model = ConsolePortTemplate
fields = ['id', 'device_type', 'name', 'type'] fields = ['id', 'device_type', 'name', 'label', 'type']
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@ -261,7 +258,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
fields = ['id', 'device_type', 'name', 'type'] fields = ['id', 'device_type', 'name', 'label', 'type']
class PowerPortTemplateSerializer(ValidatedModelSerializer): class PowerPortTemplateSerializer(ValidatedModelSerializer):
@ -274,7 +271,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = PowerPortTemplate model = PowerPortTemplate
fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw'] fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw']
class PowerOutletTemplateSerializer(ValidatedModelSerializer): class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@ -295,7 +292,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = PowerOutletTemplate model = PowerOutletTemplate
fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg'] fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg']
class InterfaceTemplateSerializer(ValidatedModelSerializer): class InterfaceTemplateSerializer(ValidatedModelSerializer):
@ -304,7 +301,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = ['id', 'device_type', 'name', 'type', 'mgmt_only'] fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only']
class RearPortTemplateSerializer(ValidatedModelSerializer): class RearPortTemplateSerializer(ValidatedModelSerializer):
@ -331,7 +328,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = DeviceBayTemplate model = DeviceBayTemplate
fields = ['id', 'device_type', 'name'] fields = ['id', 'device_type', 'name', 'label']
# #
@ -362,7 +359,7 @@ class PlatformSerializer(ValidatedModelSerializer):
] ]
class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
device_type = NestedDeviceTypeSerializer() device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer() device_role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
@ -377,7 +374,6 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
parent_device = serializers.SerializerMethodField() parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True) virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Device model = Device
@ -433,7 +429,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField() method = serializers.DictField()
class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
@ -441,17 +437,16 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
required=False required=False
) )
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = [ fields = [
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags', 'connection_status', 'cable', 'tags',
] ]
class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
@ -459,17 +454,16 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
required=False required=False
) )
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = ConsolePort model = ConsolePort
fields = [ fields = [
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint', 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags', 'connection_status', 'cable', 'tags',
] ]
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
@ -487,19 +481,16 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
cable = NestedCableSerializer( cable = NestedCableSerializer(
read_only=True read_only=True
) )
tags = TagListSerializerField(
required=False
)
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = [ fields = [
'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', 'id', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags', 'connected_endpoint', 'connection_status', 'cable', 'tags',
] ]
class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField( type = ChoiceField(
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
@ -507,17 +498,16 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
required=False required=False
) )
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = PowerPort model = PowerPort
fields = [ fields = [
'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', 'id', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags', 'connected_endpoint', 'connection_status', 'cable', 'tags',
] ]
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices) type = ChoiceField(choices=InterfaceTypeChoices)
lag = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True)
@ -530,13 +520,12 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
many=True many=True
) )
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
count_ipaddresses = serializers.IntegerField(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', 'id', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
'tagged_vlans', 'tags', 'count_ipaddresses', 'tagged_vlans', 'tags', 'count_ipaddresses',
] ]
@ -562,11 +551,10 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
return super().validate(data) return super().validate(data)
class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer): class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = RearPort model = RearPort
@ -584,38 +572,35 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name'] fields = ['id', 'url', 'name']
class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer): class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
rear_port = FrontPortRearPortSerializer() rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = FrontPort model = FrontPort
fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags'] fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags']
class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True) installed_device = NestedDeviceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags'] fields = ['id', 'device', 'name', 'label', 'description', 'installed_device', 'tags']
# #
# Inventory items # Inventory items
# #
class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator # Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = InventoryItem model = InventoryItem
@ -629,7 +614,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
# Cables # Cables
# #
class CableSerializer(ValidatedModelSerializer): class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
termination_a_type = ContentTypeField( termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
) )
@ -645,7 +630,7 @@ class CableSerializer(ValidatedModelSerializer):
model = Cable model = Cable
fields = [ fields = [
'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', 'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id',
'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
] ]
def _get_termination(self, obj, side): def _get_termination(self, obj, side):
@ -708,9 +693,8 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
# Virtual chassis # Virtual chassis
# #
class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
master = NestedDeviceSerializer() master = NestedDeviceSerializer()
tags = TagListSerializerField(required=False)
member_count = serializers.IntegerField(read_only=True) member_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
@ -722,7 +706,7 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
# Power panels # Power panels
# #
class PowerPanelSerializer(ValidatedModelSerializer): class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer()
rack_group = NestedRackGroupSerializer( rack_group = NestedRackGroupSerializer(
required=False, required=False,
@ -733,10 +717,10 @@ class PowerPanelSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = PowerPanel model = PowerPanel
fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count'] fields = ['id', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count']
class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer): class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
power_panel = NestedPowerPanelSerializer() power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer( rack = NestedRackSerializer(
required=False, required=False,
@ -759,9 +743,6 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
choices=PowerFeedPhaseChoices, choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE default=PowerFeedPhaseChoices.PHASE_SINGLE
) )
tags = TagListSerializerField(
required=False
)
class Meta: class Meta:
model = PowerFeed model = PowerFeed

View File

@ -395,7 +395,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
)) ))
# Verify user permission # Verify user permission
if not request.user.has_perm('dcim.napalm_read'): if not request.user.has_perm('dcim.napalm_read_device'):
return HttpResponseForbidden() return HttpResponseForbidden()
# Connect to the device # Connect to the device
@ -502,13 +502,13 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
return Response(serializer.data) return Response(serializer.data)
class FrontPortViewSet(ModelViewSet): class FrontPortViewSet(CableTraceMixin, ModelViewSet):
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
serializer_class = serializers.FrontPortSerializer serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilterSet filterset_class = filters.FrontPortFilterSet
class RearPortViewSet(ModelViewSet): class RearPortViewSet(CableTraceMixin, ModelViewSet):
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
serializer_class = serializers.RearPortSerializer serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilterSet filterset_class = filters.RearPortFilterSet

View File

@ -276,6 +276,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_L620P = 'nema-l6-20p' TYPE_NEMA_L620P = 'nema-l6-20p'
TYPE_NEMA_L630P = 'nema-l6-30p' TYPE_NEMA_L630P = 'nema-l6-30p'
TYPE_NEMA_L650P = 'nema-l6-50p' TYPE_NEMA_L650P = 'nema-l6-50p'
TYPE_NEMA_L1420P = 'nema-l14-20p'
TYPE_NEMA_L1430P = 'nema-l14-30p'
TYPE_NEMA_L2120P = 'nema-l21-20p'
TYPE_NEMA_L2130P = 'nema-l21-30p'
# California style # California style
TYPE_CS6361C = 'cs6361c' TYPE_CS6361C = 'cs6361c'
TYPE_CS6365C = 'cs6365c' TYPE_CS6365C = 'cs6365c'
@ -337,6 +341,10 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L620P, 'NEMA L6-20P'), (TYPE_NEMA_L620P, 'NEMA L6-20P'),
(TYPE_NEMA_L630P, 'NEMA L6-30P'), (TYPE_NEMA_L630P, 'NEMA L6-30P'),
(TYPE_NEMA_L650P, 'NEMA L6-50P'), (TYPE_NEMA_L650P, 'NEMA L6-50P'),
(TYPE_NEMA_L1420P, 'NEMA L14-20P'),
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
)), )),
('California Style', ( ('California Style', (
(TYPE_CS6361C, 'CS6361C'), (TYPE_CS6361C, 'CS6361C'),
@ -405,6 +413,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_L620R = 'nema-l6-20r' TYPE_NEMA_L620R = 'nema-l6-20r'
TYPE_NEMA_L630R = 'nema-l6-30r' TYPE_NEMA_L630R = 'nema-l6-30r'
TYPE_NEMA_L650R = 'nema-l6-50r' TYPE_NEMA_L650R = 'nema-l6-50r'
TYPE_NEMA_L1420R = 'nema-l14-20r'
TYPE_NEMA_L1430R = 'nema-l14-30r'
TYPE_NEMA_L2120R = 'nema-l21-20r'
TYPE_NEMA_L2130R = 'nema-l21-30r'
# California style # California style
TYPE_CS6360C = 'CS6360C' TYPE_CS6360C = 'CS6360C'
TYPE_CS6364C = 'CS6364C' TYPE_CS6364C = 'CS6364C'
@ -467,6 +479,10 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L620R, 'NEMA L6-20R'), (TYPE_NEMA_L620R, 'NEMA L6-20R'),
(TYPE_NEMA_L630R, 'NEMA L6-30R'), (TYPE_NEMA_L630R, 'NEMA L6-30R'),
(TYPE_NEMA_L650R, 'NEMA L6-50R'), (TYPE_NEMA_L650R, 'NEMA L6-50R'),
(TYPE_NEMA_L1420R, 'NEMA L14-20R'),
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
)), )),
('California Style', ( ('California Style', (
(TYPE_CS6360C, 'CS6360C'), (TYPE_CS6360C, 'CS6360C'),

View File

@ -4,7 +4,7 @@ from django.contrib.auth.models import User
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet from tenancy.filters import TenancyFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.constants import COLOR_CHOICES from utilities.choices import ColorChoices
from utilities.filters import ( from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter,
@ -62,12 +62,12 @@ __all__ = (
class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
label='Parent region (ID)', label='Parent region (ID)',
) )
parent = django_filters.ModelMultipleChoiceFilter( parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug', field_name='parent__slug',
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Parent region (slug)', label='Parent region (slug)',
) )
@ -87,13 +87,13 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
null_value=None null_value=None
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='region', field_name='region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='region', field_name='region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -131,35 +131,35 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.unrestricted(),
label='Rack group (ID)', label='Rack group (ID)',
) )
parent = django_filters.ModelMultipleChoiceFilter( parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug', field_name='parent__slug',
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Rack group (slug)', label='Rack group (slug)',
) )
@ -182,36 +182,36 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
group_id = TreeNodeMultipleChoiceFilter( group_id = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.unrestricted(),
field_name='group', field_name='group',
lookup_expr='in', lookup_expr='in',
label='Rack group (ID)', label='Rack group (ID)',
) )
group = TreeNodeMultipleChoiceFilter( group = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.unrestricted(),
field_name='group', field_name='group',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -222,12 +222,12 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
null_value=None null_value=None
) )
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackRole.objects.all(), queryset=RackRole.objects.unrestricted(),
label='Role (ID)', label='Role (ID)',
) )
role = django_filters.ModelMultipleChoiceFilter( role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug', field_name='role__slug',
queryset=RackRole.objects.all(), queryset=RackRole.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
@ -261,28 +261,28 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
label='Search', label='Search',
) )
rack_id = django_filters.ModelMultipleChoiceFilter( rack_id = django_filters.ModelMultipleChoiceFilter(
queryset=Rack.objects.all(), queryset=Rack.objects.unrestricted(),
label='Rack (ID)', label='Rack (ID)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack__site', field_name='rack__site',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='rack__site__slug', field_name='rack__site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
group_id = TreeNodeMultipleChoiceFilter( group_id = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.unrestricted(),
field_name='rack__group', field_name='rack__group',
lookup_expr='in', lookup_expr='in',
label='Rack group (ID)', label='Rack group (ID)',
) )
group = TreeNodeMultipleChoiceFilter( group = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.unrestricted(),
field_name='rack__group', field_name='rack__group',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -298,6 +298,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
to_field_name='username', to_field_name='username',
label='User (name)', label='User (name)',
) )
tag = TagFilter()
class Meta: class Meta:
model = RackReservation model = RackReservation
@ -327,12 +328,12 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil
label='Search', label='Search',
) )
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.unrestricted(),
label='Manufacturer (ID)', label='Manufacturer (ID)',
) )
manufacturer = django_filters.ModelMultipleChoiceFilter( manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug', field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Manufacturer (slug)', label='Manufacturer (slug)',
) )
@ -409,7 +410,7 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
devicetype_id = django_filters.ModelMultipleChoiceFilter( devicetype_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.unrestricted(),
field_name='device_type_id', field_name='device_type_id',
label='Device type (ID)', label='Device type (ID)',
) )
@ -481,12 +482,12 @@ class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer', field_name='manufacturer',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.unrestricted(),
label='Manufacturer (ID)', label='Manufacturer (ID)',
) )
manufacturer = django_filters.ModelMultipleChoiceFilter( manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug', field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Manufacturer (slug)', label='Manufacturer (slug)',
) )
@ -509,81 +510,81 @@ class DeviceFilterSet(
) )
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer', field_name='device_type__manufacturer',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.unrestricted(),
label='Manufacturer (ID)', label='Manufacturer (ID)',
) )
manufacturer = django_filters.ModelMultipleChoiceFilter( manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer__slug', field_name='device_type__manufacturer__slug',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Manufacturer (slug)', label='Manufacturer (slug)',
) )
device_type_id = django_filters.ModelMultipleChoiceFilter( device_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.unrestricted(),
label='Device type (ID)', label='Device type (ID)',
) )
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_role_id', field_name='device_role_id',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.unrestricted(),
label='Role (ID)', label='Role (ID)',
) )
role = django_filters.ModelMultipleChoiceFilter( role = django_filters.ModelMultipleChoiceFilter(
field_name='device_role__slug', field_name='device_role__slug',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
platform_id = django_filters.ModelMultipleChoiceFilter( platform_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(), queryset=Platform.objects.unrestricted(),
label='Platform (ID)', label='Platform (ID)',
) )
platform = django_filters.ModelMultipleChoiceFilter( platform = django_filters.ModelMultipleChoiceFilter(
field_name='platform__slug', field_name='platform__slug',
queryset=Platform.objects.all(), queryset=Platform.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Platform (slug)', label='Platform (slug)',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site name (slug)', label='Site name (slug)',
) )
rack_group_id = TreeNodeMultipleChoiceFilter( rack_group_id = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.unrestricted(),
field_name='rack__group', field_name='rack__group',
lookup_expr='in', lookup_expr='in',
label='Rack group (ID)', label='Rack group (ID)',
) )
rack_id = django_filters.ModelMultipleChoiceFilter( rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack', field_name='rack',
queryset=Rack.objects.all(), queryset=Rack.objects.unrestricted(),
label='Rack (ID)', label='Rack (ID)',
) )
cluster_id = django_filters.ModelMultipleChoiceFilter( cluster_id = django_filters.ModelMultipleChoiceFilter(
queryset=Cluster.objects.all(), queryset=Cluster.objects.unrestricted(),
label='VM cluster (ID)', label='VM cluster (ID)',
) )
model = django_filters.ModelMultipleChoiceFilter( model = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__slug', field_name='device_type__slug',
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Device model (slug)', label='Device model (slug)',
) )
@ -608,7 +609,7 @@ class DeviceFilterSet(
) )
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_chassis', field_name='virtual_chassis',
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.unrestricted(),
label='Virtual chassis (ID)', label='Virtual chassis (ID)',
) )
virtual_chassis_member = django_filters.BooleanFilter( virtual_chassis_member = django_filters.BooleanFilter(
@ -706,13 +707,13 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='device__site__region', field_name='device__site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='device__site__region', field_name='device__site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -720,22 +721,22 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site', field_name='device__site',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug', field_name='device__site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site name (slug)', label='Site name (slug)',
) )
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.unrestricted(),
label='Device (ID)', label='Device (ID)',
) )
device = django_filters.ModelMultipleChoiceFilter( device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name', field_name='device__name',
queryset=Device.objects.all(), queryset=Device.objects.unrestricted(),
to_field_name='name', to_field_name='name',
label='Device (name)', label='Device (name)',
) )
@ -842,7 +843,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet):
) )
lag_id = django_filters.ModelMultipleChoiceFilter( lag_id = django_filters.ModelMultipleChoiceFilter(
field_name='lag', field_name='lag',
queryset=Interface.objects.all(), queryset=Interface.objects.unrestricted(),
label='LAG interface (ID)', label='LAG interface (ID)',
) )
mac_address = MultiValueMACAddressFilter() mac_address = MultiValueMACAddressFilter()
@ -949,13 +950,13 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='device__site__region', field_name='device__site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='device__site__region', field_name='device__site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -963,35 +964,35 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet):
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site', field_name='device__site',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug', field_name='device__site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site name (slug)', label='Site name (slug)',
) )
device_id = django_filters.ModelChoiceFilter( device_id = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.unrestricted(),
label='Device (ID)', label='Device (ID)',
) )
device = django_filters.ModelChoiceFilter( device = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.unrestricted(),
to_field_name='name', to_field_name='name',
label='Device (name)', label='Device (name)',
) )
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItem.objects.all(), queryset=InventoryItem.objects.unrestricted(),
label='Parent inventory item (ID)', label='Parent inventory item (ID)',
) )
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.unrestricted(),
label='Manufacturer (ID)', label='Manufacturer (ID)',
) )
manufacturer = django_filters.ModelMultipleChoiceFilter( manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug', field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Manufacturer (slug)', label='Manufacturer (slug)',
) )
@ -1022,13 +1023,13 @@ class VirtualChassisFilterSet(BaseFilterSet):
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='master__site__region', field_name='master__site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='master__site__region', field_name='master__site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -1036,23 +1037,23 @@ class VirtualChassisFilterSet(BaseFilterSet):
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='master__site', field_name='master__site',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='master__site__slug', field_name='master__site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site name (slug)', label='Site name (slug)',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter( tenant_id = django_filters.ModelMultipleChoiceFilter(
field_name='master__tenant', field_name='master__tenant',
queryset=Tenant.objects.all(), queryset=Tenant.objects.unrestricted(),
label='Tenant (ID)', label='Tenant (ID)',
) )
tenant = django_filters.ModelMultipleChoiceFilter( tenant = django_filters.ModelMultipleChoiceFilter(
field_name='master__tenant__slug', field_name='master__tenant__slug',
queryset=Tenant.objects.all(), queryset=Tenant.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Tenant (slug)', label='Tenant (slug)',
) )
@ -1084,7 +1085,7 @@ class CableFilterSet(BaseFilterSet):
choices=CableStatusChoices choices=CableStatusChoices
) )
color = django_filters.MultipleChoiceFilter( color = django_filters.MultipleChoiceFilter(
choices=COLOR_CHOICES choices=ColorChoices
) )
device_id = MultiValueNumberFilter( device_id = MultiValueNumberFilter(
method='filter_device' method='filter_device'
@ -1117,6 +1118,7 @@ class CableFilterSet(BaseFilterSet):
method='filter_device', method='filter_device',
field_name='device__tenant__slug' field_name='device__tenant__slug'
) )
tag = TagFilter()
class Meta: class Meta:
model = Cable model = Cable
@ -1237,34 +1239,35 @@ class PowerPanelFilterSet(BaseFilterSet):
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site name (slug)', label='Site name (slug)',
) )
rack_group_id = TreeNodeMultipleChoiceFilter( rack_group_id = TreeNodeMultipleChoiceFilter(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.unrestricted(),
field_name='rack_group', field_name='rack_group',
lookup_expr='in', lookup_expr='in',
label='Rack group (ID)', label='Rack group (ID)',
) )
tag = TagFilter()
class Meta: class Meta:
model = PowerPanel model = PowerPanel
@ -1285,13 +1288,13 @@ class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='power_panel__site__region', field_name='power_panel__site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='power_panel__site__region', field_name='power_panel__site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
@ -1299,22 +1302,22 @@ class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site', field_name='power_panel__site',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site__slug', field_name='power_panel__site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site name (slug)', label='Site name (slug)',
) )
power_panel_id = django_filters.ModelMultipleChoiceFilter( power_panel_id = django_filters.ModelMultipleChoiceFilter(
queryset=PowerPanel.objects.all(), queryset=PowerPanel.objects.unrestricted(),
label='Power panel (ID)', label='Power panel (ID)',
) )
rack_id = django_filters.ModelMultipleChoiceFilter( rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack', field_name='rack',
queryset=Rack.objects.all(), queryset=Rack.objects.unrestricted(),
label='Rack (ID)', label='Rack (ID)',
) )
tag = TagFilter() tag = TagFilter()

View File

@ -9,7 +9,6 @@ from django.utils.safestring import mark_safe
from mptt.forms import TreeNodeChoiceField from mptt.forms import TreeNodeChoiceField
from netaddr import EUI from netaddr import EUI
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from taggit.forms import TagField
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
from circuits.models import Circuit, Provider from circuits.models import Circuit, Provider
@ -17,18 +16,19 @@ from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
LocalConfigContextFilterForm, LocalConfigContextFilterForm,
) )
from extras.models import Tag
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
from ipam.models import IPAddress, VLAN from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, BulkRenameForm, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup, VirtualMachine from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .constants import * from .constants import *
from .models import ( from .models import (
@ -128,28 +128,26 @@ class InterfaceCommonForm:
}) })
class BulkRenameForm(forms.Form): class LabeledComponentForm(BootstrapMixin, forms.Form):
""" name_pattern = ExpandableNameField(
An extendable form to be used for renaming device components in bulk. label='Name'
""" )
find = forms.CharField() label_pattern = ExpandableNameField(
replace = forms.CharField() label='Label',
use_regex = forms.BooleanField( required=False
required=False,
initial=True,
label='Use regular expressions'
) )
def clean(self): def clean(self):
# Validate regular expression in "find" field # Validate that the number of components being created from both the name_pattern and label_pattern are equal
if self.cleaned_data['use_regex']: name_pattern_count = len(self.cleaned_data['name_pattern'])
try: label_pattern_count = len(self.cleaned_data['label_pattern'])
re.compile(self.cleaned_data['find']) if label_pattern_count and name_pattern_count != label_pattern_count:
except re.error:
raise forms.ValidationError({ raise forms.ValidationError({
'find': "Invalid regular expression" 'label_pattern': 'The provided name pattern will create {} components, however {} labels will '
}) 'be generated. These counts must match.'.format(
name_pattern_count, label_pattern_count)
}, code='label_pattern_mismatch')
# #
@ -226,7 +224,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
) )
slug = SlugField() slug = SlugField()
comments = CommentField() comments = CommentField()
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -364,7 +363,12 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackGroupForm(BootstrapMixin, forms.ModelForm): class RackGroupForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField( site = DynamicModelChoiceField(
queryset=Site.objects.all() queryset=Site.objects.all(),
widget=APISelect(
filter_for={
'parent': 'site_id',
}
)
) )
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.all(),
@ -482,7 +486,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False required=False
) )
comments = CommentField() comments = CommentField()
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -730,51 +735,49 @@ class RackElevationFilterForm(RackFilterForm):
# #
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
rack = forms.ModelChoiceField( site = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
widget=forms.HiddenInput() widget=APISelect(
) filter_for={
# TODO: Change this to an API-backed form field. We can't do this currently because we want to retain 'rack_group': 'site_id',
# the multi-line <select> widget for easy selection of multiple rack units. 'rack': 'site_id',
units = SimpleArrayField(
base_field=forms.IntegerField(),
widget=ArrayFieldSelectMultiple(
attrs={
'size': 10,
} }
) )
) )
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
widget=APISelect(
filter_for={
'rack': 'group_id'
}
)
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all()
)
units = NumericArrayField(
base_field=forms.IntegerField(),
help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
)
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
queryset=User.objects.order_by( queryset=User.objects.order_by(
'username' 'username'
), ),
widget=StaticSelect2() widget=StaticSelect2()
) )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ fields = [
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rack unit choices
if hasattr(self.instance, 'rack'):
self.fields['units'].widget.choices = self._get_unit_choices()
def _get_unit_choices(self):
rack = self.instance.rack
reserved_units = []
for resv in rack.reservations.exclude(pk=self.instance.pk):
for u in resv.units:
reserved_units.append(u)
unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units]
return unit_choices
class RackReservationCSVForm(CSVModelForm): class RackReservationCSVForm(CSVModelForm):
site = CSVModelChoiceField( site = CSVModelChoiceField(
@ -826,7 +829,7 @@ class RackReservationCSVForm(CSVModelForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=RackReservation.objects.all(), queryset=RackReservation.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
@ -852,6 +855,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
model = RackReservation
field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant'] field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant']
q = forms.CharField( q = forms.CharField(
required=False, required=False,
@ -873,6 +877,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
null_option=True, null_option=True,
) )
) )
tag = TagFilterField(model)
# #
@ -908,7 +913,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
slug_source='model' slug_source='model'
) )
comments = CommentField() comments = CommentField()
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -933,6 +939,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
model = DeviceType model = DeviceType
fields = [ fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'comments',
] ]
@ -1027,25 +1034,40 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Device component templates # Device component templates
# #
class ComponentTemplateCreateForm(LabeledComponentForm):
"""
Base form for the creation of device component templates.
"""
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
widget=APISelect(
filter_for={
'device_type': 'manufacturer_id'
}
)
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
widget=APISelect(
display_field='model'
)
)
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ConsolePortTemplate model = ConsolePortTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'device_type', 'name', 'label', 'type',
] ]
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
} }
class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices), choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2() widget=StaticSelect2()
@ -1072,20 +1094,14 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'device_type', 'name', 'label', 'type',
] ]
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
} }
class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices), choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2() widget=StaticSelect2()
@ -1112,20 +1128,14 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = PowerPortTemplate model = PowerPortTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
] ]
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
} }
class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices), choices=add_blank_choice(PowerPortTypeChoices),
required=False required=False
@ -1172,7 +1182,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = PowerOutletTemplate model = PowerOutletTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'power_port', 'feed_leg', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
] ]
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
@ -1189,13 +1199,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
) )
class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices), choices=add_blank_choice(PowerOutletTypeChoices),
required=False required=False
@ -1227,11 +1231,21 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=PowerOutletTemplate.objects.all(), queryset=PowerOutletTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
device_type = forms.ModelChoiceField(
queryset=DeviceType.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices), choices=add_blank_choice(PowerOutletTypeChoices),
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False
)
feed_leg = forms.ChoiceField( feed_leg = forms.ChoiceField(
choices=add_blank_choice(PowerOutletFeedLegChoices), choices=add_blank_choice(PowerOutletFeedLegChoices),
required=False, required=False,
@ -1239,7 +1253,18 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
) )
class Meta: class Meta:
nullable_fields = ('type', 'feed_leg') nullable_fields = ('type', 'power_port', 'feed_leg')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType
if 'device_type' in self.initial:
device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first()
self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type)
else:
self.fields['power_port'].choices = ()
self.fields['power_port'].widget.attrs['disabled'] = True
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
@ -1247,7 +1272,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'mgmt_only', 'device_type', 'name', 'label', 'type', 'mgmt_only',
] ]
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
@ -1255,13 +1280,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
widget=StaticSelect2() widget=StaticSelect2()
@ -1315,13 +1334,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
) )
class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form): class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=PortTypeChoices, choices=PortTypeChoices,
widget=StaticSelect2() widget=StaticSelect2()
@ -1406,13 +1419,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
} }
class RearPortTemplateCreateForm(BootstrapMixin, forms.Form): class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=PortTypeChoices, choices=PortTypeChoices,
widget=StaticSelect2(), widget=StaticSelect2(),
@ -1445,20 +1452,15 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = DeviceBayTemplate model = DeviceBayTemplate
fields = [ fields = [
'device_type', 'name', 'device_type', 'name', 'label',
] ]
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
} }
class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
device_type = DynamicModelChoiceField( pass
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
# TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet # TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet
@ -1504,7 +1506,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
class Meta: class Meta:
model = ConsolePortTemplate model = ConsolePortTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'device_type', 'name', 'label', 'type',
] ]
@ -1513,7 +1515,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta: class Meta:
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'device_type', 'name', 'label', 'type',
] ]
@ -1522,7 +1524,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta: class Meta:
model = PowerPortTemplate model = PowerPortTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
] ]
@ -1536,7 +1538,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
class Meta: class Meta:
model = PowerOutletTemplate model = PowerOutletTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'power_port', 'feed_leg', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
] ]
@ -1548,7 +1550,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
class Meta: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = [ fields = [
'device_type', 'name', 'type', 'mgmt_only', 'device_type', 'name', 'label', 'type', 'mgmt_only',
] ]
@ -1726,11 +1728,14 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False required=False
) )
comments = CommentField() comments = CommentField()
tags = TagField(required=False)
local_context_data = JSONField( local_context_data = JSONField(
required=False, required=False,
label='' label=''
) )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Device model = Device
@ -1787,18 +1792,20 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
ip_choices = [(None, '---------')] ip_choices = [(None, '---------')]
# Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member # 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 # Collect interface IPs
interface_ips = IPAddress.objects.prefetch_related('interface').filter( interface_ips = IPAddress.objects.prefetch_related('interface').filter(
address__family=family, interface_id__in=interface_ids address__family=family,
interface__in=interface_ids
) )
if interface_ips: if interface_ips:
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list)) ip_choices.append(('Interface IPs', ip_list))
# Collect NAT IPs # Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter( nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
address__family=family, nat_inside__interface__in=interface_ids address__family=family,
nat_inside__interface__in=interface_ids
) )
if nat_ips: if nat_ips:
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips] ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
@ -1957,7 +1964,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
help_text='Parent device' help_text='Parent device'
) )
device_bay = CSVModelChoiceField( device_bay = CSVModelChoiceField(
queryset=Device.objects.all(), queryset=DeviceBay.objects.all(),
to_field_name='name', to_field_name='name',
help_text='Device bay in which this device is installed' help_text='Device bay in which this device is installed'
) )
@ -1977,6 +1984,20 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')} params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params) self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
def clean(self):
super().clean()
# Set parent_bay reverse relationship
device_bay = self.cleaned_data.get('device_bay')
if device_bay:
self.instance.parent_bay = device_bay
# Inherit site and rack from parent device
parent = self.cleaned_data.get('parent')
if parent:
self.instance.site = parent.site
self.instance.rack = parent.rack
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
@ -2174,23 +2195,32 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
# #
# Bulk device component creation # Device components
# #
class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): class ComponentCreateForm(LabeledComponentForm):
"""
Base form for the creation of device components.
"""
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class DeviceBulkAddComponentForm(LabeledComponentForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
name_pattern = ExpandableNameField( tags = DynamicModelMultipleChoiceField(
label='Name' 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'))
# #
# Console ports # Console ports
@ -2208,27 +2238,22 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
class ConsolePortForm(BootstrapMixin, forms.ModelForm): class ConsolePortForm(BootstrapMixin, forms.ModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
class Meta: class Meta:
model = ConsolePort model = ConsolePort
fields = [ fields = [
'device', 'name', 'type', 'description', 'tags', 'device', 'name', 'label', 'type', 'description', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
} }
class ConsolePortCreateForm(BootstrapMixin, forms.Form): class ConsolePortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices), choices=add_blank_choice(ConsolePortTypeChoices),
required=False, required=False,
@ -2238,7 +2263,8 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form):
max_length=100, max_length=100,
required=False required=False
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -2294,7 +2320,8 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -2308,13 +2335,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
} }
class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): class ConsoleServerPortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices), choices=add_blank_choice(ConsolePortTypeChoices),
required=False, required=False,
@ -2324,7 +2345,8 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
max_length=100, max_length=100,
required=False required=False
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -2394,7 +2416,8 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
class PowerPortForm(BootstrapMixin, forms.ModelForm): class PowerPortForm(BootstrapMixin, forms.ModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -2408,13 +2431,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
} }
class PowerPortCreateForm(BootstrapMixin, forms.Form): class PowerPortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices), choices=add_blank_choice(PowerPortTypeChoices),
required=False, required=False,
@ -2434,7 +2451,8 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form):
max_length=100, max_length=100,
required=False required=False
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -2494,7 +2512,8 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
queryset=PowerPort.objects.all(), queryset=PowerPort.objects.all(),
required=False required=False
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -2517,13 +2536,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
) )
class PowerOutletCreateForm(BootstrapMixin, forms.Form): class PowerOutletCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices), choices=add_blank_choice(PowerOutletTypeChoices),
required=False, required=False,
@ -2541,7 +2554,8 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form):
max_length=100, max_length=100,
required=False required=False
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -2700,14 +2714,15 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
}, },
) )
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
class Meta: class Meta:
model = Interface model = Interface
fields = [ 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', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
] ]
widgets = { widgets = {
@ -2742,13 +2757,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
widget=StaticSelect2(), widget=StaticSelect2(),
@ -2787,7 +2796,8 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
required=False, required=False,
widget=StaticSelect2(), widget=StaticSelect2(),
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
untagged_vlan = DynamicModelChoiceField( untagged_vlan = DynamicModelChoiceField(
@ -2831,7 +2841,7 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
class InterfaceBulkCreateForm( class InterfaceBulkCreateForm(
form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']), form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description']),
DeviceBulkAddComponentForm DeviceBulkAddComponentForm
): ):
pass pass
@ -2929,12 +2939,6 @@ class InterfaceBulkDisconnectForm(ConfirmationForm):
class InterfaceCSVForm(CSVModelForm): class InterfaceCSVForm(CSVModelForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False,
to_field_name='name'
)
virtual_machine = CSVModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
to_field_name='name' to_field_name='name'
) )
lag = CSVModelChoiceField( lag = CSVModelChoiceField(
@ -2999,7 +3003,8 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
class FrontPortForm(BootstrapMixin, forms.ModelForm): class FrontPortForm(BootstrapMixin, forms.ModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -3025,13 +3030,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm):
# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
class FrontPortCreateForm(BootstrapMixin, forms.Form): class FrontPortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=PortTypeChoices, choices=PortTypeChoices,
widget=StaticSelect2(), widget=StaticSelect2(),
@ -3190,7 +3189,8 @@ class RearPortFilterForm(DeviceComponentFilterForm):
class RearPortForm(BootstrapMixin, forms.ModelForm): class RearPortForm(BootstrapMixin, forms.ModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -3205,13 +3205,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm):
} }
class RearPortCreateForm(BootstrapMixin, forms.Form): class RearPortCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
type = forms.ChoiceField( type = forms.ChoiceField(
choices=PortTypeChoices, choices=PortTypeChoices,
widget=StaticSelect2(), widget=StaticSelect2(),
@ -3293,30 +3287,23 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
class DeviceBayForm(BootstrapMixin, forms.ModelForm): class DeviceBayForm(BootstrapMixin, forms.ModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = [ fields = [
'device', 'name', 'description', 'tags', 'device', 'name', 'label', 'description', 'tags',
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
} }
class DeviceBayCreateForm(BootstrapMixin, forms.Form): class DeviceBayCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField( pass
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
tags = TagField(
required=False
)
class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
@ -3344,7 +3331,8 @@ class DeviceBayBulkCreateForm(
form_from_model(DeviceBay, ['description', 'tags']), form_from_model(DeviceBay, ['description', 'tags']),
DeviceBulkAddComponentForm DeviceBulkAddComponentForm
): ):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -3648,17 +3636,26 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
class CableForm(BootstrapMixin, forms.ModelForm): class CableForm(BootstrapMixin, forms.ModelForm):
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'type', 'status', 'label', 'color', 'length', 'length_unit', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
] ]
widgets = { widgets = {
'status': StaticSelect2, 'status': StaticSelect2,
'type': StaticSelect2, 'type': StaticSelect2,
'length_unit': StaticSelect2, 'length_unit': StaticSelect2,
} }
error_messages = {
'length': {
'max_value': 'Maximum length is 32767 (any unit)'
}
}
class CableCSVForm(CSVModelForm): class CableCSVForm(CSVModelForm):
@ -3780,7 +3777,7 @@ class CableCSVForm(CSVModelForm):
return length_unit if length_unit is not None else '' return length_unit if length_unit is not None else ''
class CableBulkEditForm(BootstrapMixin, BulkEditForm): class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Cable.objects.all(), queryset=Cable.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -3893,6 +3890,7 @@ class CableFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
label='Device' label='Device'
) )
tag = TagFilterField(model)
# #
@ -3968,7 +3966,8 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False required=False
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -4116,7 +4115,8 @@ class DeviceSelectionForm(forms.Form):
class VirtualChassisForm(BootstrapMixin, forms.ModelForm): class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -4306,11 +4306,15 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm):
queryset=RackGroup.objects.all(), queryset=RackGroup.objects.all(),
required=False required=False
) )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = PowerPanel model = PowerPanel
fields = [ fields = [
'site', 'rack_group', 'name', 'site', 'rack_group', 'name', 'tags',
] ]
@ -4340,7 +4344,7 @@ class PowerPanelCSVForm(CSVModelForm):
self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params)
class PowerPanelBulkEditForm(BootstrapMixin, BulkEditForm): class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=PowerPanel.objects.all(), queryset=PowerPanel.objects.all(),
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
@ -4401,6 +4405,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
null_option=True, null_option=True,
) )
) )
tag = TagFilterField(model)
# #
@ -4426,7 +4431,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
required=False required=False
) )
comments = CommentField() comments = CommentField()
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )

View File

@ -22,7 +22,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='device', 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( migrations.AddField(
model_name='platform', model_name='platform',

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='device', 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( migrations.AlterModelOptions(
name='rack', name='rack',

View File

@ -79,42 +79,42 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='consoleport', model_name='consoleport',
name='_name', 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( migrations.AddField(
model_name='consoleserverport', model_name='consoleserverport',
name='_name', 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( migrations.AddField(
model_name='devicebay', model_name='devicebay',
name='_name', 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( migrations.AddField(
model_name='frontport', model_name='frontport',
name='_name', 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( migrations.AddField(
model_name='inventoryitem', model_name='inventoryitem',
name='_name', 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( migrations.AddField(
model_name='poweroutlet', model_name='poweroutlet',
name='_name', 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( migrations.AddField(
model_name='powerport', model_name='powerport',
name='_name', 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( migrations.AddField(
model_name='rearport', model_name='rearport',
name='_name', 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( migrations.RunPython(
code=naturalize_consoleports, code=naturalize_consoleports,

View File

@ -75,37 +75,37 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='consoleporttemplate', model_name='consoleporttemplate',
name='_name', 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( migrations.AddField(
model_name='consoleserverporttemplate', model_name='consoleserverporttemplate',
name='_name', 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( migrations.AddField(
model_name='devicebaytemplate', model_name='devicebaytemplate',
name='_name', 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( migrations.AddField(
model_name='frontporttemplate', model_name='frontporttemplate',
name='_name', 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( migrations.AddField(
model_name='poweroutlettemplate', model_name='poweroutlettemplate',
name='_name', 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( migrations.AddField(
model_name='powerporttemplate', model_name='powerporttemplate',
name='_name', 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( migrations.AddField(
model_name='rearporttemplate', model_name='rearporttemplate',
name='_name', 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( migrations.RunPython(
code=naturalize_consoleporttemplates, code=naturalize_consoleporttemplates,

View File

@ -30,7 +30,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='device', 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( migrations.AlterModelOptions(
name='rack', name='rack',
@ -43,17 +43,17 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='device', model_name='device',
name='_name', 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( migrations.AddField(
model_name='rack', model_name='rack',
name='_name', 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( migrations.AddField(
model_name='site', model_name='site',
name='_name', 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( migrations.RunPython(
code=naturalize_sites, code=naturalize_sites,

View File

@ -35,12 +35,12 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='interface', model_name='interface',
name='_name', 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( migrations.AddField(
model_name='interfacetemplate', model_name='interfacetemplate',
name='_name', 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( migrations.RunPython(
code=naturalize_interfacetemplates, code=naturalize_interfacetemplates,

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.6 on 2020-05-26 13:33
from django.db import migrations
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0105_interface_name_collation'),
]
operations = [
migrations.AlterField(
model_name='devicerole',
name='color',
field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
),
migrations.AlterField(
model_name='rackrole',
name='color',
field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
),
]

View File

@ -0,0 +1,73 @@
# Generated by Django 3.0.7 on 2020-06-04 20:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0106_role_default_color'),
]
operations = [
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='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='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='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),
),
]

View File

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

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.6 on 2020-06-22 16:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0108_add_tags'),
('virtualization', '0016_replicate_interfaces'),
]
operations = [
migrations.RemoveField(
model_name='interface',
name='virtual_machine',
),
]

View File

@ -23,8 +23,11 @@ from dcim.fields import ASNField
from dcim.elevations import RackElevationSVG from dcim.elevations import RackElevationSVG
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.mptt import TreeManager
from utilities.utils import serialize_object, to_meters from utilities.utils import serialize_object, to_meters
from utilities.validators import ExclusionValidator from utilities.validators import ExclusionValidator
from .device_component_templates import ( from .device_component_templates import (
@ -32,11 +35,12 @@ from .device_component_templates import (
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
) )
from .device_components import ( from .device_components import (
CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet, BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem,
PowerPort, RearPort, PowerOutlet, PowerPort, RearPort,
) )
__all__ = ( __all__ = (
'BaseInterface',
'Cable', 'Cable',
'CableTermination', 'CableTermination',
'ConsolePort', 'ConsolePort',
@ -102,6 +106,8 @@ class Region(MPTTModel, ChangeLoggedModel):
blank=True blank=True
) )
objects = TreeManager()
csv_headers = ['name', 'slug', 'parent', 'description'] csv_headers = ['name', 'slug', 'parent', 'description']
class MPTTMeta: class MPTTMeta:
@ -243,6 +249,8 @@ class Site(ChangeLoggedModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
@ -325,6 +333,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
blank=True blank=True
) )
objects = TreeManager()
csv_headers = ['site', 'parent', 'name', 'slug', 'description'] csv_headers = ['site', 'parent', 'name', 'slug', 'description']
class Meta: class Meta:
@ -379,12 +389,16 @@ class RackRole(ChangeLoggedModel):
slug = models.SlugField( slug = models.SlugField(
unique=True unique=True
) )
color = ColorField() color = ColorField(
default=ColorChoices.COLOR_GREY
)
description = models.CharField( description = models.CharField(
max_length=200, max_length=200,
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description'] csv_headers = ['name', 'slug', 'color', 'description']
class Meta: class Meta:
@ -523,6 +537,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
@ -680,7 +696,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
return [u for u in elevation.values()] return [u for u in elevation.values()]
def get_available_units(self, u_height=1, rack_face=None, exclude=list()): 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). 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 Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
@ -690,9 +706,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth :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) :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 # 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) devices = self.devices.unrestricted().prefetch_related('device_type').filter(position__gte=1)
if exclude is not None:
devices = devices.exclude(pk__in=exclude)
# Initialize the rack unit skeleton # Initialize the rack unit skeleton
units = list(range(1, self.u_height + 1)) units = list(range(1, self.u_height + 1))
@ -720,7 +737,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
Return a dictionary mapping all reserved units within the rack to their reservation. Return a dictionary mapping all reserved units within the rack to their reservation.
""" """
reserved_units = {} reserved_units = {}
for r in self.reservations.all(): for r in self.reservations.unrestricted():
for u in r.units: for u in r.units:
reserved_units[u] = r reserved_units[u] = r
return reserved_units return reserved_units
@ -774,7 +791,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
""" """
Determine the utilization rate of power in the rack and return it as a percentage. Determine the utilization rate of power in the rack and return it as a percentage.
""" """
power_stats = PowerFeed.objects.filter( power_stats = PowerFeed.objects.unrestricted().filter(
rack=self rack=self
).annotate( ).annotate(
allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'), allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
@ -817,6 +834,9 @@ class RackReservation(ChangeLoggedModel):
description = models.CharField( description = models.CharField(
max_length=200 max_length=200
) )
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
@ -897,6 +917,8 @@ class Manufacturer(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description'] csv_headers = ['name', 'slug', 'description']
class Meta: class Meta:
@ -979,9 +1001,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
clone_fields = [ clone_fields = [
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
] ]
@ -1190,7 +1213,9 @@ class DeviceRole(ChangeLoggedModel):
slug = models.SlugField( slug = models.SlugField(
unique=True unique=True
) )
color = ColorField() color = ColorField(
default=ColorChoices.COLOR_GREY
)
vm_role = models.BooleanField( vm_role = models.BooleanField(
default=True, default=True,
verbose_name='VM Role', verbose_name='VM Role',
@ -1201,6 +1226,8 @@ class DeviceRole(ChangeLoggedModel):
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'vm_role', 'description'] csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
class Meta: class Meta:
@ -1258,6 +1285,8 @@ class Platform(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description'] csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
class Meta: class Meta:
@ -1424,6 +1453,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
@ -1449,10 +1480,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
('rack', 'position', 'face'), ('rack', 'position', 'face'),
('virtual_chassis', 'vc_position'), ('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): def __str__(self):
return self.display_name or super().__str__() return self.display_name or super().__str__()
@ -1736,9 +1763,10 @@ class VirtualChassis(ChangeLoggedModel):
max_length=30, max_length=30,
blank=True blank=True
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['master', 'domain'] csv_headers = ['master', 'domain']
class Meta: class Meta:
@ -1807,6 +1835,9 @@ class PowerPanel(ChangeLoggedModel):
name = models.CharField( name = models.CharField(
max_length=50 max_length=50
) )
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'name'] csv_headers = ['site', 'rack_group', 'name']
@ -1911,9 +1942,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments', 'amperage', 'max_utilization', 'comments',
@ -2078,6 +2110,9 @@ class Cable(ChangeLoggedModel):
blank=True, blank=True,
null=True null=True
) )
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
@ -2110,9 +2145,9 @@ class Cable(ChangeLoggedModel):
""" """
instance = super().from_db(db, field_names, values) instance = super().from_db(db, field_names, values)
instance._orig_termination_a_type = instance.termination_a_type instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_id = instance.termination_a_id instance._orig_termination_a_id = instance.termination_a_id
instance._orig_termination_b_type = instance.termination_b_type instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_id = instance.termination_b_id instance._orig_termination_b_id = instance.termination_b_id
return instance return instance
@ -2149,14 +2184,14 @@ class Cable(ChangeLoggedModel):
if self.pk: if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.' err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
if ( if (
self.termination_a_type != self._orig_termination_a_type or self.termination_a_type_id != self._orig_termination_a_type_id or
self.termination_a_id != self._orig_termination_a_id self.termination_a_id != self._orig_termination_a_id
): ):
raise ValidationError({ raise ValidationError({
'termination_a': err_msg 'termination_a': err_msg
}) })
if ( if (
self.termination_b_type != self._orig_termination_b_type or self.termination_b_type_id != self._orig_termination_b_type_id or
self.termination_b_id != self._orig_termination_b_id self.termination_b_id != self._orig_termination_b_id
): ):
raise ValidationError({ raise ValidationError({
@ -2182,23 +2217,29 @@ class Cable(ChangeLoggedModel):
# Check that termination types are compatible # Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Incompatible termination types: {} and {}".format(
self.termination_a_type, self.termination_b_type
))
# A RearPort with multiple positions must be connected to a component with an equal number of positions
if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
if self.termination_a.positions != self.termination_b.positions:
raise ValidationError( raise ValidationError(
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format( f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
self.termination_a, self.termination_a.positions,
self.termination_b, self.termination_b.positions
) )
# A RearPort with multiple positions must be connected to a RearPort with an equal 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, RearPort):
raise ValidationError(
"Rear ports with multiple positions may only be connected to other rear ports"
)
elif term_a.positions != term_b.positions:
raise ValidationError(
f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. "
f"Both terminations must have the same number of positions."
) )
# A termination point cannot be connected to itself # A termination point cannot be connected to itself
if self.termination_a == self.termination_b: if self.termination_a == self.termination_b:
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type)) raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
# A front port cannot be connected to its corresponding rear port # A front port cannot be connected to its corresponding rear port
if ( if (

View File

@ -6,6 +6,7 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from extras.models import ObjectChange from extras.models import ObjectChange
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object from utilities.utils import serialize_object
from .device_components import ( from .device_components import (
@ -26,10 +27,16 @@ __all__ = (
class ComponentTemplateModel(models.Model): class ComponentTemplateModel(models.Model):
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
abstract = True abstract = True
def __str__(self):
if self.label:
return f"{self.name} ({self.label})"
return self.name
def instantiate(self, device): def instantiate(self, device):
""" """
Instantiate a new component on the specified Device. Instantiate a new component on the specified Device.
@ -69,6 +76,11 @@ class ConsolePortTemplate(ComponentTemplateModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
@ -79,9 +91,6 @@ class ConsolePortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name') ordering = ('device_type', '_name')
unique_together = ('device_type', 'name') unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device): def instantiate(self, device):
return ConsolePort( return ConsolePort(
device=device, device=device,
@ -107,6 +116,11 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
@ -117,9 +131,6 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name') ordering = ('device_type', '_name')
unique_together = ('device_type', 'name') unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device): def instantiate(self, device):
return ConsoleServerPort( return ConsoleServerPort(
device=device, device=device,
@ -145,6 +156,11 @@ class PowerPortTemplate(ComponentTemplateModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
@ -167,9 +183,6 @@ class PowerPortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name') ordering = ('device_type', '_name')
unique_together = ('device_type', 'name') unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device): def instantiate(self, device):
return PowerPort( return PowerPort(
device=device, device=device,
@ -197,6 +210,11 @@ class PowerOutletTemplate(ComponentTemplateModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
@ -220,9 +238,6 @@ class PowerOutletTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name') ordering = ('device_type', '_name')
unique_together = ('device_type', 'name') unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def clean(self): def clean(self):
# Validate power port assignment # Validate power port assignment
@ -263,6 +278,11 @@ class InterfaceTemplate(ComponentTemplateModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=InterfaceTypeChoices choices=InterfaceTypeChoices
@ -276,9 +296,6 @@ class InterfaceTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name') ordering = ('device_type', '_name')
unique_together = ('device_type', 'name') unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device): def instantiate(self, device):
return Interface( return Interface(
device=device, device=device,
@ -418,14 +435,16 @@ class DeviceBayTemplate(ComponentTemplateModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
class Meta: class Meta:
ordering = ('device_type', '_name') ordering = ('device_type', '_name')
unique_together = ('device_type', 'name') unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device): def instantiate(self, device):
return DeviceBay( return DeviceBay(
device=device, device=device,

View File

@ -16,9 +16,9 @@ from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.utils import serialize_object from utilities.utils import serialize_object
from virtualization.choices import VMInterfaceTypeChoices
__all__ = ( __all__ = (
@ -41,22 +41,23 @@ class ComponentModel(models.Model):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
abstract = True abstract = True
def to_objectchange(self, action): def __str__(self):
# Annotate the parent Device/VM if self.label:
try: return f"{self.name} ({self.label})"
parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None) return self.name
except ObjectDoesNotExist:
# The parent device/VM has already been deleted
parent = None
def to_objectchange(self, action):
# Annotate the parent Device
return ObjectChange( return ObjectChange(
changed_object=self, changed_object=self,
object_repr=str(self), object_repr=str(self),
action=action, action=action,
related_object=parent, related_object=self.device,
object_data=serialize_object(self) object_data=serialize_object(self)
) )
@ -231,6 +232,11 @@ class ConsolePort(CableTermination, ComponentModel):
name = models.CharField( name = models.CharField(
max_length=50 max_length=50
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
_name = NaturalOrderingField( _name = NaturalOrderingField(
target_field='name', target_field='name',
max_length=100, max_length=100,
@ -261,9 +267,6 @@ class ConsolePort(CableTermination, ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
@ -298,6 +301,11 @@ class ConsoleServerPort(CableTermination, ComponentModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=ConsolePortTypeChoices, choices=ConsolePortTypeChoices,
@ -316,9 +324,6 @@ class ConsoleServerPort(CableTermination, ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
@ -353,6 +358,11 @@ class PowerPort(CableTermination, ComponentModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=PowerPortTypeChoices, choices=PowerPortTypeChoices,
@ -397,9 +407,6 @@ class PowerPort(CableTermination, ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
@ -516,6 +523,11 @@ class PowerOutlet(CableTermination, ComponentModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=PowerOutletTypeChoices, choices=PowerOutletTypeChoices,
@ -547,9 +559,6 @@ class PowerOutlet(CableTermination, ComponentModel):
ordering = ('device', '_name') ordering = ('device', '_name')
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return self.device.get_absolute_url() return self.device.get_absolute_url()
@ -576,26 +585,7 @@ class PowerOutlet(CableTermination, ComponentModel):
# Interfaces # Interfaces
# #
@extras_features('graphs', 'export_templates', 'webhooks') class BaseInterface(models.Model):
class Interface(CableTermination, ComponentModel):
"""
A network interface within a Device or VirtualMachine. 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( name = models.CharField(
max_length=64 max_length=64
) )
@ -605,6 +595,47 @@ class Interface(CableTermination, ComponentModel):
max_length=100, max_length=100,
blank=True blank=True
) )
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, BaseInterface):
"""
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
)
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
_connected_interface = models.OneToOneField( _connected_interface = models.OneToOneField(
to='self', to='self',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -635,30 +666,11 @@ class Interface(CableTermination, ComponentModel):
max_length=50, max_length=50,
choices=InterfaceTypeChoices 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( mgmt_only = models.BooleanField(
default=False, default=False,
verbose_name='OOB Management', verbose_name='OOB Management',
help_text='This interface is used only for out-of-band management' help_text='This interface is used only for out-of-band management'
) )
mode = models.CharField(
max_length=50,
choices=InterfaceModeChoices,
blank=True
)
untagged_vlan = models.ForeignKey( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -673,28 +685,28 @@ class Interface(CableTermination, ComponentModel):
blank=True, blank=True,
verbose_name='Tagged VLANs' 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) tags = TaggableManager(through=TaggedItem)
csv_headers = [ csv_headers = [
'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'device', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode',
'description', 'mode',
] ]
class Meta: class Meta:
# TODO: ordering and unique_together should include virtual_machine
ordering = ('device', CollateAsChar('_name')) ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk}) return reverse('dcim:interface', kwargs={'pk': self.pk})
def to_csv(self): def to_csv(self):
return ( return (
self.device.identifier if self.device else None, self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.name, self.name,
self.lag.name if self.lag else None, self.lag.name if self.lag else None,
self.get_type_display(), self.get_type_display(),
@ -708,18 +720,6 @@ class Interface(CableTermination, ComponentModel):
def clean(self): 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 # Virtual interfaces cannot be connected
if self.type in NONCONNECTABLE_IFACE_TYPES and ( if self.type in NONCONNECTABLE_IFACE_TYPES and (
self.cable or getattr(self, 'circuit_termination', False) self.cable or getattr(self, 'circuit_termination', False)
@ -755,7 +755,7 @@ class Interface(CableTermination, ComponentModel):
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
raise ValidationError({ raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " '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): def save(self, *args, **kwargs):
@ -770,21 +770,6 @@ class Interface(CableTermination, ComponentModel):
return super().save(*args, **kwargs) 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 @property
def connected_endpoint(self): def connected_endpoint(self):
""" """
@ -823,7 +808,7 @@ class Interface(CableTermination, ComponentModel):
@property @property
def parent(self): def parent(self):
return self.device or self.virtual_machine return self.device
@property @property
def is_connectable(self): def is_connectable(self):
@ -994,6 +979,11 @@ class DeviceBay(ComponentModel):
max_length=100, max_length=100,
blank=True blank=True
) )
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
installed_device = models.OneToOneField( installed_device = models.OneToOneField(
to='dcim.Device', to='dcim.Device',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -1010,6 +1000,8 @@ class DeviceBay(ComponentModel):
unique_together = ('device', 'name') unique_together = ('device', 'name')
def __str__(self): def __str__(self):
if self.label:
return '{} - {} ({})'.format(self.device.name, self.name, self.label)
return '{} - {}'.format(self.device.name, self.name) return '{} - {}'.format(self.device.name, self.name)
def get_absolute_url(self): def get_absolute_url(self):

View File

@ -2,7 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn
from .models import ( from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@ -72,15 +72,6 @@ RACKROLE_ACTIONS = """
{% endif %} {% endif %}
""" """
RACK_ROLE = """
{% if record.role %}
{% load helpers %}
<label class="label" style="color: {{ record.role.color|fgcolor }}; background-color: #{{ record.role.color }}">{{ value }}</label>
{% else %}
&mdash;
{% endif %}
"""
RACK_DEVICE_COUNT = """ RACK_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a> <a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
""" """
@ -137,11 +128,6 @@ PLATFORM_ACTIONS = """
{% endif %} {% endif %}
""" """
DEVICE_ROLE = """
{% load helpers %}
<label class="label" style="color: {{ record.device_role.color|fgcolor }}; background-color: #{{ record.device_role.color }}">{{ value }}</label>
"""
STATUS_LABEL = """ STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span> <span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
""" """
@ -325,9 +311,7 @@ class RackTable(BaseTable):
status = tables.TemplateColumn( status = tables.TemplateColumn(
template_code=STATUS_LABEL template_code=STATUS_LABEL
) )
role = tables.TemplateColumn( role = ColoredLabelColumn()
template_code=RACK_ROLE
)
u_height = tables.TemplateColumn( u_height = tables.TemplateColumn(
template_code="{{ record.u_height }}U", template_code="{{ record.u_height }}U",
verbose_name='Height' verbose_name='Height'
@ -399,6 +383,9 @@ class RackReservationTable(BaseTable):
orderable=False, orderable=False,
verbose_name='Units' verbose_name='Units'
) )
tags = TagColumn(
url_name='dcim:rackreservation_list'
)
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=RACKRESERVATION_ACTIONS, template_code=RACKRESERVATION_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}}, attrs={'td': {'class': 'text-right noprint'}},
@ -408,7 +395,8 @@ class RackReservationTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RackReservation model = RackReservation
fields = ( 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 = ( default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
@ -482,11 +470,14 @@ class DeviceTypeTable(BaseTable):
# Device type components # Device type components
# #
class ConsolePortTemplateTable(BaseTable): class ComponentTemplateTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.Column( name = tables.Column(
order_by=('_name',) order_by=('_name',)
) )
class ConsolePortTemplateTable(ComponentTemplateTable):
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=get_component_template_actions('consoleporttemplate'), template_code=get_component_template_actions('consoleporttemplate'),
attrs={'td': {'class': 'text-right noprint'}}, attrs={'td': {'class': 'text-right noprint'}},
@ -495,7 +486,7 @@ class ConsolePortTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConsolePortTemplate model = ConsolePortTemplate
fields = ('pk', 'name', 'type', 'actions') fields = ('pk', 'name', 'label', 'type', 'actions')
empty_text = "None" empty_text = "None"
@ -511,11 +502,7 @@ class ConsolePortImportTable(BaseTable):
empty_text = False empty_text = False
class ConsoleServerPortTemplateTable(BaseTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',)
)
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=get_component_template_actions('consoleserverporttemplate'), template_code=get_component_template_actions('consoleserverporttemplate'),
attrs={'td': {'class': 'text-right noprint'}}, attrs={'td': {'class': 'text-right noprint'}},
@ -524,7 +511,7 @@ class ConsoleServerPortTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConsoleServerPortTemplate model = ConsoleServerPortTemplate
fields = ('pk', 'name', 'type', 'actions') fields = ('pk', 'name', 'label', 'type', 'actions')
empty_text = "None" empty_text = "None"
@ -540,11 +527,7 @@ class ConsoleServerPortImportTable(BaseTable):
empty_text = False empty_text = False
class PowerPortTemplateTable(BaseTable): class PowerPortTemplateTable(ComponentTemplateTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',)
)
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=get_component_template_actions('powerporttemplate'), template_code=get_component_template_actions('powerporttemplate'),
attrs={'td': {'class': 'text-right noprint'}}, attrs={'td': {'class': 'text-right noprint'}},
@ -553,7 +536,7 @@ class PowerPortTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerPortTemplate model = PowerPortTemplate
fields = ('pk', 'name', 'type', 'maximum_draw', 'allocated_draw', 'actions') fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'actions')
empty_text = "None" empty_text = "None"
@ -569,11 +552,7 @@ class PowerPortImportTable(BaseTable):
empty_text = False empty_text = False
class PowerOutletTemplateTable(BaseTable): class PowerOutletTemplateTable(ComponentTemplateTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',)
)
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=get_component_template_actions('poweroutlettemplate'), template_code=get_component_template_actions('poweroutlettemplate'),
attrs={'td': {'class': 'text-right noprint'}}, attrs={'td': {'class': 'text-right noprint'}},
@ -582,7 +561,7 @@ class PowerOutletTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerOutletTemplate model = PowerOutletTemplate
fields = ('pk', 'name', 'type', 'power_port', 'feed_leg', 'actions') fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'actions')
empty_text = "None" empty_text = "None"
@ -598,10 +577,9 @@ class PowerOutletImportTable(BaseTable):
empty_text = False empty_text = False
class InterfaceTemplateTable(BaseTable): class InterfaceTemplateTable(ComponentTemplateTable):
pk = ToggleColumn() mgmt_only = BooleanColumn(
mgmt_only = tables.TemplateColumn( verbose_name='Management Only'
template_code="{% if value %}OOB Management{% endif %}"
) )
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=get_component_template_actions('interfacetemplate'), template_code=get_component_template_actions('interfacetemplate'),
@ -611,7 +589,7 @@ class InterfaceTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = InterfaceTemplate model = InterfaceTemplate
fields = ('pk', 'name', 'mgmt_only', 'type', 'actions') fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'actions')
empty_text = "None" empty_text = "None"
@ -620,26 +598,16 @@ class InterfaceImportTable(BaseTable):
viewname='dcim:device', viewname='dcim:device',
args=[Accessor('device.pk')] args=[Accessor('device.pk')]
) )
virtual_machine = tables.LinkColumn(
viewname='virtualization:virtualmachine',
args=[Accessor('virtual_machine.pk')],
verbose_name='Virtual Machine'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface model = Interface
fields = ( fields = (
'device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'device', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'mode',
'mgmt_only', 'mode',
) )
empty_text = False empty_text = False
class FrontPortTemplateTable(BaseTable): class FrontPortTemplateTable(ComponentTemplateTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',)
)
rear_port_position = tables.Column( rear_port_position = tables.Column(
verbose_name='Position' verbose_name='Position'
) )
@ -651,7 +619,7 @@ class FrontPortTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = FrontPortTemplate model = FrontPortTemplate
fields = ('pk', 'name', 'type', 'rear_port', 'rear_port_position', 'actions') fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'actions')
empty_text = "None" empty_text = "None"
@ -667,11 +635,7 @@ class FrontPortImportTable(BaseTable):
empty_text = False empty_text = False
class RearPortTemplateTable(BaseTable): class RearPortTemplateTable(ComponentTemplateTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',)
)
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=get_component_template_actions('rearporttemplate'), template_code=get_component_template_actions('rearporttemplate'),
attrs={'td': {'class': 'text-right noprint'}}, attrs={'td': {'class': 'text-right noprint'}},
@ -680,7 +644,7 @@ class RearPortTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RearPortTemplate model = RearPortTemplate
fields = ('pk', 'name', 'type', 'positions', 'actions') fields = ('pk', 'name', 'label', 'type', 'positions', 'actions')
empty_text = "None" empty_text = "None"
@ -696,11 +660,7 @@ class RearPortImportTable(BaseTable):
empty_text = False empty_text = False
class DeviceBayTemplateTable(BaseTable): class DeviceBayTemplateTable(ComponentTemplateTable):
pk = ToggleColumn()
name = tables.Column(
order_by=('_name',)
)
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
template_code=get_component_template_actions('devicebaytemplate'), template_code=get_component_template_actions('devicebaytemplate'),
attrs={'td': {'class': 'text-right noprint'}}, attrs={'td': {'class': 'text-right noprint'}},
@ -709,7 +669,7 @@ class DeviceBayTemplateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceBayTemplate model = DeviceBayTemplate
fields = ('pk', 'name', 'actions') fields = ('pk', 'name', 'label', 'actions')
empty_text = "None" empty_text = "None"
@ -806,8 +766,7 @@ class DeviceTable(BaseTable):
viewname='dcim:rack', viewname='dcim:rack',
args=[Accessor('rack.pk')] args=[Accessor('rack.pk')]
) )
device_role = tables.TemplateColumn( device_role = ColoredLabelColumn(
template_code=DEVICE_ROLE,
verbose_name='Role' verbose_name='Role'
) )
device_type = tables.LinkColumn( device_type = tables.LinkColumn(
@ -898,13 +857,14 @@ class DeviceImportTable(BaseTable):
class DeviceComponentDetailTable(BaseTable): class DeviceComponentDetailTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
device = tables.LinkColumn()
name = tables.Column(order_by=('_name',)) name = tables.Column(order_by=('_name',))
cable = tables.LinkColumn() cable = tables.LinkColumn()
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
order_by = ('device', 'name') order_by = ('device', 'name')
fields = ('pk', 'device', 'name', 'type', 'description', 'cable') fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
sequence = ('pk', 'device', 'name', 'type', 'description', 'cable') sequence = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
class ConsolePortTable(BaseTable): class ConsolePortTable(BaseTable):
@ -912,11 +872,10 @@ class ConsolePortTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConsolePort model = ConsolePort
fields = ('name', 'type') fields = ('name', 'label', 'type')
class ConsolePortDetailTable(DeviceComponentDetailTable): class ConsolePortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta):
pass pass
@ -927,11 +886,10 @@ class ConsoleServerPortTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConsoleServerPort model = ConsoleServerPort
fields = ('name', 'description') fields = ('name', 'label', 'description')
class ConsoleServerPortDetailTable(DeviceComponentDetailTable): class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta):
pass pass
@ -942,11 +900,10 @@ class PowerPortTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerPort model = PowerPort
fields = ('name', 'type') fields = ('name', 'label', 'type')
class PowerPortDetailTable(DeviceComponentDetailTable): class PowerPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta):
pass pass
@ -957,11 +914,10 @@ class PowerOutletTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerOutlet model = PowerOutlet
fields = ('name', 'type', 'description') fields = ('name', 'label', 'type', 'description')
class PowerOutletDetailTable(DeviceComponentDetailTable): class PowerOutletDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta): class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta):
pass pass
@ -971,18 +927,15 @@ class InterfaceTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface model = Interface
fields = ('name', 'type', 'lag', 'enabled', 'mgmt_only', 'description') fields = ('name', 'label', 'type', 'lag', 'enabled', 'mgmt_only', 'description')
class InterfaceDetailTable(DeviceComponentDetailTable): class InterfaceDetailTable(DeviceComponentDetailTable):
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
name = tables.LinkColumn()
enabled = BooleanColumn() enabled = BooleanColumn()
class Meta(InterfaceTable.Meta): class Meta(DeviceComponentDetailTable.Meta, InterfaceTable.Meta):
order_by = ('parent', 'name') fields = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable')
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable') sequence = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
class FrontPortTable(BaseTable): class FrontPortTable(BaseTable):
@ -990,12 +943,11 @@ class FrontPortTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = FrontPort model = FrontPort
fields = ('name', 'type', 'rear_port', 'rear_port_position', 'description') fields = ('name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
empty_text = "None" empty_text = "None"
class FrontPortDetailTable(DeviceComponentDetailTable): class FrontPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta):
pass pass
@ -1006,12 +958,11 @@ class RearPortTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RearPort model = RearPort
fields = ('name', 'type', 'positions', 'description') fields = ('name', 'label', 'type', 'positions', 'description')
empty_text = "None" empty_text = "None"
class RearPortDetailTable(DeviceComponentDetailTable): class RearPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta): class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta):
pass pass
@ -1022,16 +973,15 @@ class DeviceBayTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceBay model = DeviceBay
fields = ('name', 'description') fields = ('name', 'label', 'description')
class DeviceBayDetailTable(DeviceComponentDetailTable): class DeviceBayDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
installed_device = tables.LinkColumn() installed_device = tables.LinkColumn()
class Meta(DeviceBayTable.Meta): class Meta(DeviceBayTable.Meta):
fields = ('pk', 'name', 'device', 'installed_device', 'description') fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
sequence = ('pk', 'name', 'device', 'installed_device', 'description') sequence = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
exclude = ('cable',) exclude = ('cable',)
@ -1086,12 +1036,15 @@ class CableTable(BaseTable):
order_by='_abs_length' order_by='_abs_length'
) )
color = ColorColumn() color = ColorColumn()
tags = TagColumn(
url_name='dcim:cable_list'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Cable model = Cable
fields = ( fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type', 'color', 'length', 'status', 'type', 'color', 'length', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
@ -1195,7 +1148,7 @@ class InventoryItemTable(BaseTable):
args=[Accessor('device.pk')] args=[Accessor('device.pk')]
) )
manufacturer = tables.Column( manufacturer = tables.Column(
accessor=Accessor('manufacturer.name') accessor=Accessor('manufacturer')
) )
discovered = BooleanColumn() discovered = BooleanColumn()
@ -1245,10 +1198,13 @@ class PowerPanelTable(BaseTable):
template_code=POWERPANEL_POWERFEED_COUNT, template_code=POWERPANEL_POWERFEED_COUNT,
verbose_name='Feeds' verbose_name='Feeds'
) )
tags = TagColumn(
url_name='dcim:powerpanel_list'
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerPanel 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') default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')

File diff suppressed because it is too large Load Diff

View File

@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase):
# Assign primary IPs for filtering # Assign primary IPs for filtering
ipaddresses = ( ipaddresses = (
IPAddress(address='192.0.2.1/24', interface=interfaces[0]), IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', interface=interfaces[1]), IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
) )
IPAddress.objects.bulk_create(ipaddresses) IPAddress.objects.bulk_create(ipaddresses)
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0]) Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])

View File

@ -116,3 +116,45 @@ class DeviceTestCase(TestCase):
# Check that the initial value for the cluster group is set automatically when assigning the cluster # 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) 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)

View File

@ -76,6 +76,8 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Site(name='Site 3', slug='site-3', region=regions[0]), Site(name='Site 3', slug='site-3', region=regions[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Site X', 'name': 'Site X',
'slug': 'site-x', 'slug': 'site-x',
@ -94,7 +96,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'contact_phone': '123-555-9999', 'contact_phone': '123-555-9999',
'contact_email': 'hank@stricklandpropane.com', 'contact_email': 'hank@stricklandpropane.com',
'comments': 'Test site', 'comments': 'Test site',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -196,12 +198,15 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'), RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'rack': rack.pk, 'rack': rack.pk,
'units': [10, 11, 12], 'units': "10,11,12",
'user': user3.pk, 'user': user3.pk,
'tenant': None, 'tenant': None,
'description': 'Rack reservation', 'description': 'Rack reservation',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -249,6 +254,8 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Rack(name='Rack 3', site=sites[0]), Rack(name='Rack 3', site=sites[0]),
)) ))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Rack X', 'name': 'Rack X',
'facility_id': 'Facility X', 'facility_id': 'Facility X',
@ -267,7 +274,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_depth': 500, 'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'comments': 'Some comments', 'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -321,7 +328,17 @@ 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.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = DeviceType model = DeviceType
@classmethod @classmethod
@ -339,6 +356,8 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]), DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'manufacturer': manufacturers[1].pk, 'manufacturer': manufacturers[1].pk,
'model': 'Device Type X', 'model': 'Device Type X',
@ -348,7 +367,7 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'is_full_depth': True, 'is_full_depth': True,
'subdevice_role': '', # CharField 'subdevice_role': '', # CharField
'comments': 'Some comments', 'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -366,6 +385,7 @@ manufacturer: Generic
model: TEST-1000 model: TEST-1000
slug: test-1000 slug: test-1000
u_height: 2 u_height: 2
comments: test comment
console-ports: console-ports:
- name: Console Port 1 - name: Console Port 1
type: de-9 type: de-9
@ -456,6 +476,7 @@ device-bays:
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
dt = DeviceType.objects.get(model='TEST-1000') dt = DeviceType.objects.get(model='TEST-1000')
self.assertEqual(dt.comments, 'test comment')
# Verify all of the components were created # Verify all of the components were created
self.assertEqual(dt.consoleport_templates.count(), 3) self.assertEqual(dt.consoleport_templates.count(), 3)
@ -697,6 +718,8 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
cls.bulk_create_data = { cls.bulk_create_data = {
'device_type': devicetypes[1].pk, 'device_type': devicetypes[1].pk,
'name_pattern': 'Interface Template [4-6]', '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, 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True, 'mgmt_only': True,
} }
@ -790,12 +813,16 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
} }
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): # TODO: Change base class to DeviceComponentTemplateViewTestCase
# Blocked by absence of bulk edit view for DeviceBays
class DeviceBayTemplateTestCase(
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.BulkCreateObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = DeviceBayTemplate model = DeviceBayTemplate
# Disable inapplicable views
test_bulk_edit_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@ -928,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]), 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 = { cls.form_data = {
'device_type': devicetypes[1].pk, 'device_type': devicetypes[1].pk,
'device_role': deviceroles[1].pk, 'device_role': deviceroles[1].pk,
@ -948,7 +977,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'vc_position': None, 'vc_position': None,
'vc_priority': None, 'vc_priority': None,
'comments': 'A new device', 'comments': 'A new device',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
'local_context_data': None, 'local_context_data': None,
} }
@ -982,20 +1011,24 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsolePort(device=device, name='Console Port 3'), ConsolePort(device=device, name='Console Port 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'name': 'Console Port X', 'name': 'Console Port X',
'type': ConsolePortTypeChoices.TYPE_RJ45, 'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port', 'description': 'A console port',
'tags': 'Alpha,Bravo,Charlie', 'tags': sorted([t.pk for t in tags]),
} }
cls.bulk_create_data = { cls.bulk_create_data = {
'device': device.pk, 'device': device.pk,
'name_pattern': 'Console Port [4-6]', '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, 'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port', 'description': 'A console port',
'tags': 'Alpha,Bravo,Charlie', 'tags': sorted([t.pk for t in tags]),
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -1024,12 +1057,14 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsoleServerPort(device=device, name='Console Server Port 3'), ConsoleServerPort(device=device, name='Console Server Port 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'name': 'Console Server Port X', 'name': 'Console Server Port X',
'type': ConsolePortTypeChoices.TYPE_RJ45, 'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port', 'description': 'A console server port',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_create_data = { cls.bulk_create_data = {
@ -1037,12 +1072,11 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name_pattern': 'Console Server Port [4-6]', 'name_pattern': 'Console Server Port [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45, 'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port', 'description': 'A console server port',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
'device': device.pk, 'type': ConsolePortTypeChoices.TYPE_RJ11,
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'New description', 'description': 'New description',
} }
@ -1067,6 +1101,8 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerPort(device=device, name='Power Port 3'), PowerPort(device=device, name='Power Port 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'name': 'Power Port X', 'name': 'Power Port X',
@ -1074,7 +1110,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'maximum_draw': 100, 'maximum_draw': 100,
'allocated_draw': 50, 'allocated_draw': 50,
'description': 'A power port', 'description': 'A power port',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_create_data = { cls.bulk_create_data = {
@ -1084,7 +1120,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'maximum_draw': 100, 'maximum_draw': 100,
'allocated_draw': 50, 'allocated_draw': 50,
'description': 'A power port', 'description': 'A power port',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -1121,6 +1157,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]), PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'name': 'Power Outlet X', 'name': 'Power Outlet X',
@ -1128,7 +1166,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'power_port': powerports[1].pk, 'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet', 'description': 'A power outlet',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_create_data = { cls.bulk_create_data = {
@ -1138,12 +1176,11 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'power_port': powerports[1].pk, 'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet', 'description': 'A power outlet',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
'device': device.pk, 'type': PowerOutletTypeChoices.TYPE_IEC_C15,
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[1].pk, 'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'New description', 'description': 'New description',
@ -1183,6 +1220,8 @@ class InterfaceTestCase(
) )
VLAN.objects.bulk_create(vlans) VLAN.objects.bulk_create(vlans)
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'virtual_machine': None, 'virtual_machine': None,
@ -1197,7 +1236,7 @@ class InterfaceTestCase(
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk, 'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]], '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 = { cls.bulk_create_data = {
@ -1213,13 +1252,12 @@ class InterfaceTestCase(
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk, 'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]], '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 = { cls.bulk_edit_data = {
'device': device.pk, 'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': True,
'enabled': False,
'lag': interfaces[3].pk, 'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'), 'mac_address': EUI('01:02:03:04:05:06'),
'mtu': 2000, 'mtu': 2000,
@ -1261,6 +1299,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]), FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'name': 'Front Port X', 'name': 'Front Port X',
@ -1268,7 +1308,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'rear_port': rearports[3].pk, 'rear_port': rearports[3].pk,
'rear_port_position': 1, 'rear_port_position': 1,
'description': 'New description', 'description': 'New description',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_create_data = { cls.bulk_create_data = {
@ -1279,7 +1319,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'{}:1'.format(rp.pk) for rp in rearports[3:6] '{}:1'.format(rp.pk) for rp in rearports[3:6]
], ],
'description': 'New description', 'description': 'New description',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -1308,13 +1348,15 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
RearPort(device=device, name='Rear Port 3'), RearPort(device=device, name='Rear Port 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'name': 'Rear Port X', 'name': 'Rear Port X',
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
'positions': 3, 'positions': 3,
'description': 'A rear port', 'description': 'A rear port',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_create_data = { cls.bulk_create_data = {
@ -1323,7 +1365,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'type': PortTypeChoices.TYPE_8P8C, 'type': PortTypeChoices.TYPE_8P8C,
'positions': 3, 'positions': 3,
'description': 'A rear port', 'description': 'A rear port',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -1355,18 +1397,20 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
DeviceBay(device=device, name='Device Bay 3'), DeviceBay(device=device, name='Device Bay 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'name': 'Device Bay X', 'name': 'Device Bay X',
'description': 'A device bay', 'description': 'A device bay',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_create_data = { cls.bulk_create_data = {
'device': device.pk, 'device': device.pk,
'name_pattern': 'Device Bay [4-6]', 'name_pattern': 'Device Bay [4-6]',
'description': 'A device bay', 'description': 'A device bay',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
@ -1395,6 +1439,8 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
InventoryItem(device=device, name='Inventory Item 3'), InventoryItem(device=device, name='Inventory Item 3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'manufacturer': manufacturer.pk, 'manufacturer': manufacturer.pk,
@ -1405,7 +1451,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
'serial': '123ABC', 'serial': '123ABC',
'asset_tag': 'ABC123', 'asset_tag': 'ABC123',
'description': 'An inventory item', 'description': 'An inventory item',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_create_data = { cls.bulk_create_data = {
@ -1417,12 +1463,10 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
'part_id': '123456', 'part_id': '123456',
'serial': '123ABC', 'serial': '123ABC',
'description': 'An inventory item', 'description': 'An inventory item',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.bulk_edit_data = { cls.bulk_edit_data = {
'device': device.pk,
'manufacturer': manufacturer.pk,
'part_id': '123456', 'part_id': '123456',
'description': 'New description', 'description': 'New description',
} }
@ -1435,12 +1479,19 @@ 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.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = Cable model = Cable
# TODO: Creation URL needs termination context
test_create_object = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -1477,6 +1528,8 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save() 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() 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) interface_ct = ContentType.objects.get_for_model(Interface)
cls.form_data = { cls.form_data = {
# Changing terminations not supported when editing an existing Cable # Changing terminations not supported when editing an existing Cable
@ -1490,6 +1543,7 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'color': 'c0c0c0', 'color': 'c0c0c0',
'length': 100, 'length': 100,
'length_unit': CableLengthUnitChoices.UNIT_FOOT, 'length_unit': CableLengthUnitChoices.UNIT_FOOT,
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -1509,16 +1563,18 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): # TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by standard creation, bulk creation views for VirtualChassis (member devices must be selected in bulk)
class VirtualChassisTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = VirtualChassis model = VirtualChassis
# Disable inapplicable tests
test_import_objects = None
# TODO: Requires special form handling
test_create_object = None
test_edit_object = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -1532,32 +1588,43 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
# Create 9 member Devices # Create 9 member Devices
device1 = Device.objects.create( devices = (
device_type=device_type, device_role=device_role, name='Device 1', site=site 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),
device2 = Device.objects.create( Device(device_type=device_type, device_role=device_role, name='Device 3', site=site),
device_type=device_type, device_role=device_role, name='Device 2', 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),
device3 = Device.objects.create( Device(device_type=device_type, device_role=device_role, name='Device 6', site=site),
device_type=device_type, device_role=device_role, name='Device 3', 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),
device4 = Device.objects.create( Device(device_type=device_type, device_role=device_role, name='Device 9', site=site),
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 two members each # Create three VirtualChassis with two members each
vc1 = VirtualChassis.objects.create(master=device1, domain='test-domain-1') vc1 = VirtualChassis.objects.create(master=devices[0], domain='domain-1')
Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc1, vc_position=2) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2)
vc2 = VirtualChassis.objects.create(master=device3, domain='test-domain-2') Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=vc1, vc_position=3)
Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2) vc2 = VirtualChassis.objects.create(master=devices[3], domain='domain-2')
vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3') Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=vc2, vc_position=2)
Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2) Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=vc2, vc_position=3)
vc3 = VirtualChassis.objects.create(master=devices[6], domain='domain-3')
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 = {
'master': devices[1].pk,
'domain': 'domain-x',
# 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.bulk_edit_data = {
'domain': 'domain-x',
}
class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@ -1585,10 +1652,13 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'), PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'),
)) ))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'site': sites[1].pk, 'site': sites[1].pk,
'rack_group': rackgroups[1].pk, 'rack_group': rackgroups[1].pk,
'name': 'Power Panel X', 'name': 'Power Panel X',
'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -1630,6 +1700,8 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]), PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
)) ))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'Power Feed X', 'name': 'Power Feed X',
'power_panel': powerpanels[1].pk, 'power_panel': powerpanels[1].pk,
@ -1642,7 +1714,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'amperage': 100, 'amperage': 100,
'max_utilization': 50, 'max_utilization': 50,
'comments': 'New comments', 'comments': 'New comments',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
# Connection # Connection
'cable': None, 'cable': None,

View File

@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceCreateView from ipam.views import ServiceEditView
from . import views from . import views
from .models import ( from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
@ -14,7 +14,7 @@ urlpatterns = [
# Regions # Regions
path('regions/', views.RegionListView.as_view(), name='region_list'), 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/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
@ -22,7 +22,7 @@ urlpatterns = [
# Sites # Sites
path('sites/', views.SiteListView.as_view(), name='site_list'), 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/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
@ -34,7 +34,7 @@ urlpatterns = [
# Rack groups # Rack groups
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), 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/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
@ -42,7 +42,7 @@ urlpatterns = [
# Rack roles # Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), 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/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
@ -50,7 +50,7 @@ urlpatterns = [
# Rack reservations # Rack reservations
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), 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/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
@ -62,7 +62,7 @@ urlpatterns = [
# Racks # Racks
path('racks/', views.RackListView.as_view(), name='rack_list'), path('racks/', views.RackListView.as_view(), name='rack_list'),
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_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/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
@ -74,7 +74,7 @@ urlpatterns = [
# Manufacturers # Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), 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/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
@ -82,7 +82,7 @@ urlpatterns = [
# Device types # Device types
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), 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/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
@ -149,7 +149,7 @@ urlpatterns = [
# Device roles # Device roles
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), 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/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
@ -157,7 +157,7 @@ urlpatterns = [
# Platforms # Platforms
path('platforms/', views.PlatformListView.as_view(), name='platform_list'), 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/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'), path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
@ -165,7 +165,7 @@ urlpatterns = [
# Devices # Devices
path('devices/', views.DeviceListView.as_view(), name='device_list'), 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/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
@ -179,7 +179,7 @@ urlpatterns = [
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'), path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports # Console ports
@ -332,7 +332,7 @@ urlpatterns = [
# Power panels # Power panels
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), 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/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'), path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
@ -343,7 +343,7 @@ urlpatterns = [
# Power feeds # Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), 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/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),

File diff suppressed because it is too large Load Diff

View File

@ -46,24 +46,19 @@ class WebhookAdmin(admin.ModelAdmin):
form = WebhookForm form = WebhookForm
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ( 'fields': ('name', 'obj_type', 'enabled')
'name', 'obj_type', 'enabled',
)
}), }),
('Events', { ('Events', {
'fields': ( 'fields': ('type_create', 'type_update', 'type_delete')
'type_create', 'type_update', 'type_delete',
)
}), }),
('HTTP Request', { ('HTTP Request', {
'fields': ( 'fields': (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
) ),
'classes': ('monospace',)
}), }),
('SSL', { ('SSL', {
'fields': ( 'fields': ('ssl_verification', 'ca_file_path')
'ssl_verification', 'ca_file_path',
)
}) })
) )
@ -121,6 +116,8 @@ class CustomLinkForm(forms.ModelForm):
'url': forms.Textarea, 'url': forms.Textarea,
} }
help_texts = { help_texts = {
'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
'first in a list.',
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links ' 'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links '
'which render as empty text will not be displayed.', 'which render as empty text will not be displayed.',
'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.', 'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
@ -136,6 +133,15 @@ class CustomLinkForm(forms.ModelForm):
@admin.register(CustomLink) @admin.register(CustomLink)
class CustomLinkAdmin(admin.ModelAdmin): class CustomLinkAdmin(admin.ModelAdmin):
fieldsets = (
('Custom Link', {
'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
}),
('Templates', {
'fields': ('text', 'url'),
'classes': ('monospace',)
})
)
list_display = [ list_display = [
'name', 'content_type', 'group_name', 'weight', 'name', 'content_type', 'group_name', 'weight',
] ]
@ -149,8 +155,29 @@ class CustomLinkAdmin(admin.ModelAdmin):
# Graphs # Graphs
# #
class GraphForm(forms.ModelForm):
class Meta:
model = Graph
exclude = ()
widgets = {
'source': forms.Textarea,
'link': forms.Textarea,
}
@admin.register(Graph) @admin.register(Graph)
class GraphAdmin(admin.ModelAdmin): class GraphAdmin(admin.ModelAdmin):
fieldsets = (
('Graph', {
'fields': ('type', 'name', 'weight')
}),
('Templates', {
'fields': ('template_language', 'source', 'link'),
'classes': ('monospace',)
})
)
form = GraphForm
list_display = [ list_display = [
'name', 'type', 'weight', 'template_language', 'source', 'name', 'type', 'weight', 'template_language', 'source',
] ]
@ -179,6 +206,15 @@ class ExportTemplateForm(forms.ModelForm):
@admin.register(ExportTemplate) @admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin): class ExportTemplateAdmin(admin.ModelAdmin):
fieldsets = (
('Export Template', {
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension')
}),
('Content', {
'fields': ('template_language', 'template_code'),
'classes': ('monospace',)
})
)
list_display = [ list_display = [
'name', 'content_type', 'description', 'mime_type', 'file_extension', 'name', 'content_type', 'description', 'mime_type', 'file_extension',
] ]

View File

@ -1,15 +1,48 @@
from rest_framework import serializers from rest_framework import serializers
from extras.models import ReportResult from extras import models
from utilities.api import WritableNestedSerializer
__all__ = [ __all__ = [
'NestedConfigContextSerializer',
'NestedExportTemplateSerializer',
'NestedGraphSerializer',
'NestedReportResultSerializer', 'NestedReportResultSerializer',
'NestedTagSerializer',
] ]
# class NestedConfigContextSerializer(WritableNestedSerializer):
# Reports url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
#
class Meta:
model = models.ConfigContext
fields = ['id', 'url', 'name']
class NestedExportTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
class Meta:
model = models.ExportTemplate
fields = ['id', 'url', 'name']
class NestedGraphSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
class Meta:
model = models.Graph
fields = ['id', 'url', 'name']
class NestedTagSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
class Meta:
model = models.Tag
fields = ['id', 'url', 'name', 'slug', 'color']
class NestedReportResultSerializer(serializers.ModelSerializer): class NestedReportResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField( url = serializers.HyperlinkedIdentityField(
@ -19,5 +52,5 @@ class NestedReportResultSerializer(serializers.ModelSerializer):
) )
class Meta: class Meta:
model = ReportResult model = models.ReportResult
fields = ['url', 'created', 'user', 'failed'] fields = ['url', 'created', 'user', 'failed']

View File

@ -95,6 +95,28 @@ class TagSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items'] fields = ['id', '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', [])
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
# #
# Image attachments # Image attachments
# #

View File

@ -108,7 +108,7 @@ class ExportTemplateViewSet(ModelViewSet):
# #
class TagViewSet(ModelViewSet): class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate( queryset = Tag.restricted.annotate(
tagged_items=Count('extras_taggeditem_items', distinct=True) tagged_items=Count('extras_taggeditem_items', distinct=True)
) )
serializer_class = serializers.TagSerializer serializer_class = serializers.TagSerializer

View File

@ -130,83 +130,83 @@ class ConfigContextFilterSet(BaseFilterSet):
) )
region_id = django_filters.ModelMultipleChoiceFilter( region_id = django_filters.ModelMultipleChoiceFilter(
field_name='regions', field_name='regions',
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
label='Region', label='Region',
) )
region = django_filters.ModelMultipleChoiceFilter( region = django_filters.ModelMultipleChoiceFilter(
field_name='regions__slug', field_name='regions__slug',
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
field_name='sites', field_name='sites',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site', label='Site',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='sites__slug', field_name='sites__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
field_name='roles', field_name='roles',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.unrestricted(),
label='Role', label='Role',
) )
role = django_filters.ModelMultipleChoiceFilter( role = django_filters.ModelMultipleChoiceFilter(
field_name='roles__slug', field_name='roles__slug',
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
platform_id = django_filters.ModelMultipleChoiceFilter( platform_id = django_filters.ModelMultipleChoiceFilter(
field_name='platforms', field_name='platforms',
queryset=Platform.objects.all(), queryset=Platform.objects.unrestricted(),
label='Platform', label='Platform',
) )
platform = django_filters.ModelMultipleChoiceFilter( platform = django_filters.ModelMultipleChoiceFilter(
field_name='platforms__slug', field_name='platforms__slug',
queryset=Platform.objects.all(), queryset=Platform.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Platform (slug)', label='Platform (slug)',
) )
cluster_group_id = django_filters.ModelMultipleChoiceFilter( cluster_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='cluster_groups', field_name='cluster_groups',
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.unrestricted(),
label='Cluster group', label='Cluster group',
) )
cluster_group = django_filters.ModelMultipleChoiceFilter( cluster_group = django_filters.ModelMultipleChoiceFilter(
field_name='cluster_groups__slug', field_name='cluster_groups__slug',
queryset=ClusterGroup.objects.all(), queryset=ClusterGroup.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Cluster group (slug)', label='Cluster group (slug)',
) )
cluster_id = django_filters.ModelMultipleChoiceFilter( cluster_id = django_filters.ModelMultipleChoiceFilter(
field_name='clusters', field_name='clusters',
queryset=Cluster.objects.all(), queryset=Cluster.objects.unrestricted(),
label='Cluster', label='Cluster',
) )
tenant_group_id = django_filters.ModelMultipleChoiceFilter( tenant_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='tenant_groups', field_name='tenant_groups',
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.unrestricted(),
label='Tenant group', label='Tenant group',
) )
tenant_group = django_filters.ModelMultipleChoiceFilter( tenant_group = django_filters.ModelMultipleChoiceFilter(
field_name='tenant_groups__slug', field_name='tenant_groups__slug',
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Tenant group (slug)', label='Tenant group (slug)',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter( tenant_id = django_filters.ModelMultipleChoiceFilter(
field_name='tenants', field_name='tenants',
queryset=Tenant.objects.all(), queryset=Tenant.objects.unrestricted(),
label='Tenant', label='Tenant',
) )
tenant = django_filters.ModelMultipleChoiceFilter( tenant = django_filters.ModelMultipleChoiceFilter(
field_name='tenants__slug', field_name='tenants__slug',
queryset=Tenant.objects.all(), queryset=Tenant.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Tenant (slug)', label='Tenant (slug)',
) )

View File

@ -1,8 +1,8 @@
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from mptt.forms import TreeNodeMultipleChoiceField from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -152,14 +152,31 @@ class TagForm(BootstrapMixin, forms.ModelForm):
] ]
class TagCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = Tag.csv_headers
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
class AddRemoveTagsForm(forms.Form): class AddRemoveTagsForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Add add/remove tags fields # Add add/remove tags fields
self.fields['add_tags'] = TagField(required=False) self.fields['add_tags'] = DynamicModelMultipleChoiceField(
self.fields['remove_tags'] = TagField(required=False) queryset=Tag.objects.all(),
required=False
)
self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class TagFilterForm(BootstrapMixin, forms.Form): class TagFilterForm(BootstrapMixin, forms.Form):
@ -421,18 +438,9 @@ class ScriptForm(BootstrapMixin, forms.Form):
help_text="Commit changes to the database (uncheck for a dry-run)" help_text="Commit changes to the database (uncheck for a dry-run)"
) )
def __init__(self, vars, *args, commit_default=True, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Dynamically populate fields for variables
for name, var in vars.items():
self.fields[name] = var.as_field()
# Toggle default commit behavior based on Meta option
if not commit_default:
self.fields['_commit'].initial = False
# Move _commit to the end of the form # Move _commit to the end of the form
commit = self.fields.pop('_commit') commit = self.fields.pop('_commit')
self.fields['_commit'] = commit self.fields['_commit'] = commit

View File

@ -2,7 +2,7 @@
# Generated by Django 1.11 on 2017-04-04 19:58 # Generated by Django 1.11 on 2017-04-04 19:58
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import extras.models import extras.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()), ('object_id', models.PositiveIntegerField()),
('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')), ('image', models.ImageField(height_field=b'image_height', upload_to=extras.utils.image_upload, width_field=b'image_width')),
('image_height', models.PositiveSmallIntegerField()), ('image_height', models.PositiveSmallIntegerField()),
('image_width', models.PositiveSmallIntegerField()), ('image_width', models.PositiveSmallIntegerField()),
('name', models.CharField(blank=True, max_length=50)), ('name', models.CharField(blank=True, max_length=50)),

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34 # Generated by Django 1.11 on 2017-05-24 15:34
from django.db import migrations, models from django.db import migrations, models
import extras.models import extras.utils
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -74,7 +74,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='imageattachment', model_name='imageattachment',
name='image', name='image',
field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'), field=models.ImageField(height_field='image_height', upload_to=extras.utils.image_upload, width_field='image_width'),
), ),
migrations.AlterField( migrations.AlterField(
model_name='topologymap', model_name='topologymap',

View File

@ -16,7 +16,6 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
], ],
options={ options={
'permissions': (('run_script', 'Can run script'),),
'managed': False, 'managed': False,
}, },
), ),

View File

@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-05-07 21:06
from django.db import migrations
import extras.models.customfields
class Migration(migrations.Migration):
dependencies = [
('extras', '0041_tag_description'),
]
operations = [
migrations.AlterModelManagers(
name='customfield',
managers=[
('objects', extras.models.customfields.CustomFieldManager()),
],
),
]

View File

@ -0,0 +1,25 @@
from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
from .models import (
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult,
Script, Webhook,
)
from .tags import Tag, TaggedItem
__all__ = (
'ConfigContext',
'ConfigContextModel',
'CustomField',
'CustomFieldChoice',
'CustomFieldModel',
'CustomFieldValue',
'CustomLink',
'ExportTemplate',
'Graph',
'ImageAttachment',
'ObjectChange',
'ReportResult',
'Script',
'Tag',
'TaggedItem',
'Webhook',
)

View File

@ -0,0 +1,308 @@
import logging
from collections import OrderedDict
from datetime import date
from django import forms
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from extras.choices import *
from extras.utils import FeatureQuery
#
# Custom fields
#
class CustomFieldModel(models.Model):
_cf = None
class Meta:
abstract = True
def cache_custom_fields(self):
"""
Cache all custom field values for this instance
"""
self._cf = {
field.name: value for field, value in self.get_custom_fields().items()
}
@property
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if self._cf is None:
self.cache_custom_fields()
return self._cf
def get_custom_fields(self):
"""
Return a dictionary of custom fields for a single object in the form {<field>: value}.
"""
fields = CustomField.objects.get_for_model(self)
# If the object exists, populate its custom fields with values
if hasattr(self, 'pk'):
values = self.custom_field_values.all()
values_dict = {cfv.field_id: cfv.value for cfv in values}
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
else:
return OrderedDict([(field, None) for field in fields])
class CustomFieldManager(models.Manager):
use_in_migrations = True
def get_for_model(self, model):
"""
Return all CustomFields assigned to the given model.
"""
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
return self.get_queryset().filter(obj_type=content_type)
class CustomField(models.Model):
obj_type = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
verbose_name='Object(s)',
limit_choices_to=FeatureQuery('custom_fields'),
help_text='The object(s) to which this field applies.'
)
type = models.CharField(
max_length=50,
choices=CustomFieldTypeChoices,
default=CustomFieldTypeChoices.TYPE_TEXT
)
name = models.CharField(
max_length=50,
unique=True
)
label = models.CharField(
max_length=50,
blank=True,
help_text='Name of the field as displayed to users (if not provided, '
'the field\'s name will be used)'
)
description = models.CharField(
max_length=200,
blank=True
)
required = models.BooleanField(
default=False,
help_text='If true, this field is required when creating new objects '
'or editing an existing object.'
)
filter_logic = models.CharField(
max_length=50,
choices=CustomFieldFilterLogicChoices,
default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
help_text='Loose matches any instance of a given string; exact '
'matches the entire field.'
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans.'
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
objects = CustomFieldManager()
class Meta:
ordering = ['weight', 'name']
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def serialize_value(self, value):
"""
Serialize the given value to a string suitable for storage as a CustomFieldValue
"""
if value is None:
return ''
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return str(int(bool(value)))
if self.type == CustomFieldTypeChoices.TYPE_DATE:
# Could be date/datetime object or string
try:
return value.strftime('%Y-%m-%d')
except AttributeError:
return value
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
# Could be ModelChoiceField or TypedChoiceField
return str(value.id) if hasattr(value, 'id') else str(value)
return value
def deserialize_value(self, serialized_value):
"""
Convert a string into the object it represents depending on the type of field
"""
if serialized_value == '':
return None
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
return int(serialized_value)
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return bool(int(serialized_value))
if self.type == CustomFieldTypeChoices.TYPE_DATE:
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.choices.get(pk=int(serialized_value))
return serialized_value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
initial = self.default if set_initial else None
required = self.required if enforce_required else False
# Integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
field = forms.IntegerField(required=required, initial=initial)
# Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = (
(None, '---------'),
(1, 'True'),
(0, 'False'),
)
if initial is not None and initial.lower() in ['true', 'yes', '1']:
initial = 1
elif initial is not None and initial.lower() in ['false', 'no', '0']:
initial = 0
else:
initial = None
field = forms.NullBooleanField(
required=required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
field = forms.DateField(required=required, initial=initial, widget=DatePicker())
# Select
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
if not required:
choices = add_blank_choice(choices)
# Set the initial value to the PK of the default choice, if any
if set_initial:
default_choice = self.choices.filter(value=self.default).first()
if default_choice:
initial = default_choice.pk
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelect2()
)
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(required=required, initial=initial)
# Text
else:
field = forms.CharField(max_length=255, required=required, initial=initial)
field.model = self
field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
if self.description:
field.help_text = self.description
return field
class CustomFieldValue(models.Model):
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='values'
)
obj_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
obj_id = models.PositiveIntegerField()
obj = GenericForeignKey(
ct_field='obj_type',
fk_field='obj_id'
)
serialized_value = models.CharField(
max_length=255
)
class Meta:
ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
unique_together = ('field', 'obj_type', 'obj_id')
def __str__(self):
return '{} {}'.format(self.obj, self.field)
@property
def value(self):
return self.field.deserialize_value(self.serialized_value)
@value.setter
def value(self, value):
self.serialized_value = self.field.serialize_value(value)
def save(self, *args, **kwargs):
# Delete this object if it no longer has a value to store
if self.pk and self.value is None:
self.delete()
else:
super().save(*args, **kwargs)
class CustomFieldChoice(models.Model):
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='choices',
limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
)
value = models.CharField(
max_length=100
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Higher weights appear lower in the list'
)
class Meta:
ordering = ['field', 'weight', 'value']
unique_together = ['field', 'value']
def __str__(self):
return self.value
def clean(self):
if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
raise ValidationError("Custom field choices can only be assigned to selection fields.")
def delete(self, using=None, keep_parents=False):
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
pk = self.pk
super().delete(using, keep_parents)
CustomFieldValue.objects.filter(
field__type=CustomFieldTypeChoices.TYPE_SELECT,
serialized_value=str(pk)
).delete()

View File

@ -1,8 +1,6 @@
import json import json
from collections import OrderedDict from collections import OrderedDict
from datetime import date
from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -12,37 +10,14 @@ from django.db import models
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Template, Context from django.template import Template, Context
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from utilities.utils import deepmerge, render_jinja2 from utilities.utils import deepmerge, render_jinja2
from .choices import * from extras.choices import *
from .constants import * from extras.constants import *
from .querysets import ConfigContextQuerySet from extras.querysets import ConfigContextQuerySet
from .utils import FeatureQuery from extras.utils import FeatureQuery, image_upload
__all__ = (
'ConfigContext',
'ConfigContextModel',
'CustomField',
'CustomFieldChoice',
'CustomFieldModel',
'CustomFieldValue',
'CustomLink',
'ExportTemplate',
'Graph',
'ImageAttachment',
'ObjectChange',
'ReportResult',
'Script',
'Tag',
'TaggedItem',
'Webhook',
)
# #
@ -174,291 +149,6 @@ class Webhook(models.Model):
return json.dumps(context, cls=JSONEncoder) return json.dumps(context, cls=JSONEncoder)
#
# Custom fields
#
class CustomFieldModel(models.Model):
_cf = None
class Meta:
abstract = True
def cache_custom_fields(self):
"""
Cache all custom field values for this instance
"""
self._cf = {
field.name: value for field, value in self.get_custom_fields().items()
}
@property
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if self._cf is None:
self.cache_custom_fields()
return self._cf
def get_custom_fields(self):
"""
Return a dictionary of custom fields for a single object in the form {<field>: value}.
"""
# Find all custom fields applicable to this type of object
content_type = ContentType.objects.get_for_model(self)
fields = CustomField.objects.filter(obj_type=content_type)
# If the object exists, populate its custom fields with values
if hasattr(self, 'pk'):
values = self.custom_field_values.all()
values_dict = {cfv.field_id: cfv.value for cfv in values}
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
else:
return OrderedDict([(field, None) for field in fields])
class CustomField(models.Model):
obj_type = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
verbose_name='Object(s)',
limit_choices_to=FeatureQuery('custom_fields'),
help_text='The object(s) to which this field applies.'
)
type = models.CharField(
max_length=50,
choices=CustomFieldTypeChoices,
default=CustomFieldTypeChoices.TYPE_TEXT
)
name = models.CharField(
max_length=50,
unique=True
)
label = models.CharField(
max_length=50,
blank=True,
help_text='Name of the field as displayed to users (if not provided, '
'the field\'s name will be used)'
)
description = models.CharField(
max_length=200,
blank=True
)
required = models.BooleanField(
default=False,
help_text='If true, this field is required when creating new objects '
'or editing an existing object.'
)
filter_logic = models.CharField(
max_length=50,
choices=CustomFieldFilterLogicChoices,
default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
help_text='Loose matches any instance of a given string; exact '
'matches the entire field.'
)
default = models.CharField(
max_length=100,
blank=True,
help_text='Default value for the field. Use "true" or "false" for booleans.'
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Fields with higher weights appear lower in a form.'
)
class Meta:
ordering = ['weight', 'name']
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def serialize_value(self, value):
"""
Serialize the given value to a string suitable for storage as a CustomFieldValue
"""
if value is None:
return ''
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return str(int(bool(value)))
if self.type == CustomFieldTypeChoices.TYPE_DATE:
# Could be date/datetime object or string
try:
return value.strftime('%Y-%m-%d')
except AttributeError:
return value
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
# Could be ModelChoiceField or TypedChoiceField
return str(value.id) if hasattr(value, 'id') else str(value)
return value
def deserialize_value(self, serialized_value):
"""
Convert a string into the object it represents depending on the type of field
"""
if serialized_value == '':
return None
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
return int(serialized_value)
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
return bool(int(serialized_value))
if self.type == CustomFieldTypeChoices.TYPE_DATE:
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
return self.choices.get(pk=int(serialized_value))
return serialized_value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
initial = self.default if set_initial else None
required = self.required if enforce_required else False
# Integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
field = forms.IntegerField(required=required, initial=initial)
# Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = (
(None, '---------'),
(1, 'True'),
(0, 'False'),
)
if initial is not None and initial.lower() in ['true', 'yes', '1']:
initial = 1
elif initial is not None and initial.lower() in ['false', 'no', '0']:
initial = 0
else:
initial = None
field = forms.NullBooleanField(
required=required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
field = forms.DateField(required=required, initial=initial, widget=DatePicker())
# Select
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
if not required:
choices = add_blank_choice(choices)
# Set the initial value to the PK of the default choice, if any
if set_initial:
default_choice = self.choices.filter(value=self.default).first()
if default_choice:
initial = default_choice.pk
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelect2()
)
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(required=required, initial=initial)
# Text
else:
field = forms.CharField(max_length=255, required=required, initial=initial)
field.model = self
field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
if self.description:
field.help_text = self.description
return field
class CustomFieldValue(models.Model):
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='values'
)
obj_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
obj_id = models.PositiveIntegerField()
obj = GenericForeignKey(
ct_field='obj_type',
fk_field='obj_id'
)
serialized_value = models.CharField(
max_length=255
)
class Meta:
ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
unique_together = ('field', 'obj_type', 'obj_id')
def __str__(self):
return '{} {}'.format(self.obj, self.field)
@property
def value(self):
return self.field.deserialize_value(self.serialized_value)
@value.setter
def value(self, value):
self.serialized_value = self.field.serialize_value(value)
def save(self, *args, **kwargs):
# Delete this object if it no longer has a value to store
if self.pk and self.value is None:
self.delete()
else:
super().save(*args, **kwargs)
class CustomFieldChoice(models.Model):
field = models.ForeignKey(
to='extras.CustomField',
on_delete=models.CASCADE,
related_name='choices',
limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
)
value = models.CharField(
max_length=100
)
weight = models.PositiveSmallIntegerField(
default=100,
help_text='Higher weights appear lower in the list'
)
class Meta:
ordering = ['field', 'weight', 'value']
unique_together = ['field', 'value']
def __str__(self):
return self.value
def clean(self):
if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
raise ValidationError("Custom field choices can only be assigned to selection fields.")
def delete(self, using=None, keep_parents=False):
# When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
pk = self.pk
super().delete(using, keep_parents)
CustomFieldValue.objects.filter(
field__type=CustomFieldTypeChoices.TYPE_SELECT,
serialized_value=str(pk)
).delete()
# #
# Custom links # Custom links
# #
@ -542,6 +232,8 @@ class Graph(models.Model):
verbose_name='Link URL' verbose_name='Link URL'
) )
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
ordering = ('type', 'weight', 'name', 'pk') # (type, weight, name) may be non-unique ordering = ('type', 'weight', 'name', 'pk') # (type, weight, name) may be non-unique
@ -609,6 +301,8 @@ class ExportTemplate(models.Model):
help_text='Extension to append to the rendered filename' help_text='Extension to append to the rendered filename'
) )
objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
ordering = ['content_type', 'name'] ordering = ['content_type', 'name']
unique_together = [ unique_together = [
@ -663,20 +357,6 @@ class ExportTemplate(models.Model):
# Image attachments # Image attachments
# #
def image_upload(instance, filename):
path = 'image-attachments/'
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
extension = filename.rsplit('.')[-1].lower()
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
filename = '.'.join([instance.name, extension])
elif instance.name:
filename = instance.name
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
class ImageAttachment(models.Model): class ImageAttachment(models.Model):
""" """
An uploaded image which is associated with an object. An uploaded image which is associated with an object.
@ -888,9 +568,6 @@ class Script(models.Model):
""" """
class Meta: class Meta:
managed = False managed = False
permissions = (
('run_script', 'Can run script'),
)
# #
@ -995,6 +672,8 @@ class ObjectChange(models.Model):
editable=False editable=False
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = [ csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data', 'related_object_type', 'related_object_id', 'object_repr', 'object_data',
@ -1038,44 +717,3 @@ class ObjectChange(models.Model):
self.object_repr, self.object_repr,
self.object_data, self.object_data,
) )
#
# Tags
#
# TODO: figure out a way around this circular import for ObjectChange
from utilities.models import ChangeLoggedModel # noqa: E402
class Tag(TagBase, ChangeLoggedModel):
color = ColorField(
default='9e9e9e'
)
description = models.CharField(
max_length=200,
blank=True,
)
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
def slugify(self, tag, i=None):
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
slug = slugify(tag, allow_unicode=True)
if i is not None:
slug += "_%d" % i
return slug
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(
to=Tag,
related_name="%(app_label)s_%(class)s_items",
on_delete=models.CASCADE
)
class Meta:
index_together = (
("content_type", "object_id")
)

View File

@ -0,0 +1,59 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.choices import ColorChoices
from utilities.fields import ColorField
from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
#
# Tags
#
class Tag(TagBase, ChangeLoggedModel):
color = ColorField(
default=ColorChoices.COLOR_GREY
)
description = models.CharField(
max_length=200,
blank=True,
)
objects = models.Manager()
restricted = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description']
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
def slugify(self, tag, i=None):
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
slug = slugify(tag, allow_unicode=True)
if i is not None:
slug += "_%d" % i
return slug
def to_csv(self):
return (
self.name,
self.slug,
self.color,
self.description
)
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(
to=Tag,
related_name="%(app_label)s_%(class)s_items",
on_delete=models.CASCADE
)
class Meta:
index_together = (
("content_type", "object_id")
)

View File

@ -2,6 +2,8 @@ from collections import OrderedDict
from django.db.models import Q, QuerySet from django.db.models import Q, QuerySet
from utilities.querysets import RestrictedQuerySet
class CustomFieldQueryset: class CustomFieldQueryset:
""" """
@ -19,7 +21,7 @@ class CustomFieldQueryset:
yield obj yield obj
class ConfigContextQuerySet(QuerySet): class ConfigContextQuerySet(RestrictedQuerySet):
def get_for_object(self, obj): def get_for_object(self, obj):
""" """

View File

@ -100,7 +100,7 @@ class Report(object):
self.active_test = None self.active_test = None
self.failed = False self.failed = False
self.logger = logging.getLogger(f"netbox.reports.{self.module}.{self.name}") self.logger = logging.getLogger(f"netbox.reports.{self.full_name}")
# Compile test methods and initialize results skeleton # Compile test methods and initialize results skeleton
test_methods = [] test_methods = []
@ -128,7 +128,7 @@ class Report(object):
@property @property
def full_name(self): def full_name(self):
return '.'.join([self.module, self.name]) return '.'.join([self.__module__, self.__class__.__name__])
def _log(self, obj, message, level=LOG_DEFAULT): def _log(self, obj, message, level=LOG_DEFAULT):
""" """

View File

@ -277,13 +277,6 @@ class BaseScript:
@classmethod @classmethod
def _get_vars(cls): def _get_vars(cls):
vars = OrderedDict() vars = OrderedDict()
# Infer order from Meta.field_order (Python 3.5 and lower)
field_order = getattr(cls.Meta, 'field_order', [])
for name in field_order:
vars[name] = getattr(cls, name)
# Default to order of declaration on class
for name, attr in cls.__dict__.items(): for name, attr in cls.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable): if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr vars[name] = attr
@ -297,8 +290,16 @@ class BaseScript:
""" """
Return a Django form suitable for populating the context data required to run this Script. Return a Django form suitable for populating the context data required to run this Script.
""" """
vars = self._get_vars() # Create a dynamic ScriptForm subclass from script variables
form = ScriptForm(vars, data, files, initial=initial, commit_default=getattr(self.Meta, 'commit_default', True)) fields = {
name: var.as_field() for name, var in self._get_vars().items()
}
FormClass = type('ScriptForm', (ScriptForm,), fields)
form = FormClass(data, files, initial=initial)
# Set initial "commit" checkbox state based on the script's Meta parameter
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
return form return form

View File

@ -18,6 +18,8 @@ def _get_registered_content(obj, method, template_context):
'object': obj, 'object': obj,
'request': template_context['request'], 'request': template_context['request'],
'settings': template_context['settings'], 'settings': template_context['settings'],
'csrf_token': template_context['csrf_token'],
'perms': template_context['perms'],
} }
model_name = obj._meta.label_lower model_name = obj._meta.label_lower

View File

@ -5,13 +5,11 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework import status from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
from extras.api.views import ScriptViewSet from extras.api.views import ScriptViewSet
from extras.models import ConfigContext, Graph, ExportTemplate, Tag from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from extras.utils import FeatureQuery from utilities.testing import APITestCase, APIViewTestCases
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase
class AppTest(APITestCase): class AppTest(APITestCase):
@ -24,172 +22,43 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class GraphTest(APITestCase): class GraphTest(APIViewTestCases.APIViewTestCase):
model = Graph
def setUp(self): brief_fields = ['id', 'name', 'url']
create_data = [
super().setUp()
site_ct = ContentType.objects.get_for_model(Site)
self.graph1 = Graph.objects.create(
type=site_ct,
name='Test Graph 1',
source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=site_ct,
name='Test Graph 2',
source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=site_ct,
name='Test Graph 3',
source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'
)
def test_get_graph(self):
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.graph1.name)
def test_list_graphs(self):
url = reverse('extras-api:graph-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_graph(self):
data = {
'type': 'dcim.site',
'name': 'Test Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
}
url = reverse('extras-api:graph-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Graph.objects.count(), 4)
graph4 = Graph.objects.get(pk=response.data['id'])
self.assertEqual(graph4.type, ContentType.objects.get_for_model(Site))
self.assertEqual(graph4.name, data['name'])
self.assertEqual(graph4.source, data['source'])
def test_create_graph_bulk(self):
data = [
{ {
'type': 'dcim.site', 'type': 'dcim.site',
'name': 'Test Graph 4', 'name': 'Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4', 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
}, },
{ {
'type': 'dcim.site', 'type': 'dcim.site',
'name': 'Test Graph 5', 'name': 'Graph 5',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5', 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
}, },
{ {
'type': 'dcim.site', 'type': 'dcim.site',
'name': 'Test Graph 6', 'name': 'Graph 6',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6', 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
}, },
] ]
url = reverse('extras-api:graph-list') @classmethod
response = self.client.post(url, data, format='json', **self.header) def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Site)
self.assertHttpStatus(response, status.HTTP_201_CREATED) graphs = (
self.assertEqual(Graph.objects.count(), 6) Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'),
self.assertEqual(response.data[0]['name'], data[0]['name']) Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'),
self.assertEqual(response.data[1]['name'], data[1]['name']) Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'),
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_graph(self):
data = {
'type': 'dcim.site',
'name': 'Test Graph X',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=99',
}
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Graph.objects.count(), 3)
graph1 = Graph.objects.get(pk=response.data['id'])
self.assertEqual(graph1.type, ContentType.objects.get_for_model(Site))
self.assertEqual(graph1.name, data['name'])
self.assertEqual(graph1.source, data['source'])
def test_delete_graph(self):
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Graph.objects.count(), 2)
class ExportTemplateTest(APITestCase):
def setUp(self):
super().setUp()
content_type = ContentType.objects.get_for_model(Device)
self.exporttemplate1 = ExportTemplate.objects.create(
content_type=content_type, name='Test Export Template 1',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
self.exporttemplate2 = ExportTemplate.objects.create(
content_type=content_type, name='Test Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
self.exporttemplate3 = ExportTemplate.objects.create(
content_type=content_type, name='Test Export Template 3',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
) )
Graph.objects.bulk_create(graphs)
def test_get_exporttemplate(self):
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk}) class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
response = self.client.get(url, **self.header) model = ExportTemplate
brief_fields = ['id', 'name', 'url']
self.assertEqual(response.data['name'], self.exporttemplate1.name) create_data = [
def test_list_exporttemplates(self):
url = reverse('extras-api:exporttemplate-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_exporttemplate(self):
data = {
'content_type': 'dcim.device',
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}
url = reverse('extras-api:exporttemplate-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ExportTemplate.objects.count(), 4)
exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
self.assertEqual(exporttemplate4.content_type, ContentType.objects.get_for_model(Device))
self.assertEqual(exporttemplate4.name, data['name'])
self.assertEqual(exporttemplate4.template_code, data['template_code'])
def test_create_exporttemplate_bulk(self):
data = [
{ {
'content_type': 'dcim.device', 'content_type': 'dcim.device',
'name': 'Test Export Template 4', 'name': 'Test Export Template 4',
@ -207,306 +76,96 @@ class ExportTemplateTest(APITestCase):
}, },
] ]
url = reverse('extras-api:exporttemplate-list') @classmethod
response = self.client.post(url, data, format='json', **self.header) def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Device)
self.assertHttpStatus(response, status.HTTP_201_CREATED) export_templates = (
self.assertEqual(ExportTemplate.objects.count(), 6) ExportTemplate(
self.assertEqual(response.data[0]['name'], data[0]['name']) content_type=ct,
self.assertEqual(response.data[1]['name'], data[1]['name']) name='Export Template 1',
self.assertEqual(response.data[2]['name'], data[2]['name']) template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
),
def test_update_exporttemplate(self): ExportTemplate(
content_type=ct,
data = { name='Export Template 2',
'content_type': 'dcim.device', template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
'name': 'Test Export Template X', ),
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', ExportTemplate(
} content_type=ct,
name='Export Template 3',
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk}) template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
response = self.client.put(url, data, format='json', **self.header) ),
)
self.assertHttpStatus(response, status.HTTP_200_OK) ExportTemplate.objects.bulk_create(export_templates)
self.assertEqual(ExportTemplate.objects.count(), 3)
exporttemplate1 = ExportTemplate.objects.get(pk=response.data['id'])
self.assertEqual(exporttemplate1.name, data['name'])
self.assertEqual(exporttemplate1.template_code, data['template_code'])
def test_delete_exporttemplate(self):
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ExportTemplate.objects.count(), 2)
class TagTest(APITestCase): class TagTest(APIViewTestCases.APIViewTestCase):
model = Tag
def setUp(self): brief_fields = ['color', 'id', 'name', 'slug', 'url']
create_data = [
super().setUp()
self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3')
def test_get_tag(self):
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.tag1.name)
def test_list_tags(self):
url = reverse('extras-api:tag-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_tag(self):
data = {
'name': 'Test Tag 4',
'slug': 'test-tag-4',
}
url = reverse('extras-api:tag-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tag.objects.count(), 4)
tag4 = Tag.objects.get(pk=response.data['id'])
self.assertEqual(tag4.name, data['name'])
self.assertEqual(tag4.slug, data['slug'])
def test_create_tag_bulk(self):
data = [
{ {
'name': 'Test Tag 4', 'name': 'Tag 4',
'slug': 'test-tag-4', 'slug': 'tag-4',
}, },
{ {
'name': 'Test Tag 5', 'name': 'Tag 5',
'slug': 'test-tag-5', 'slug': 'tag-5',
}, },
{ {
'name': 'Test Tag 6', 'name': 'Tag 6',
'slug': 'test-tag-6', 'slug': 'tag-6',
}, },
] ]
url = reverse('extras-api:tag-list') @classmethod
response = self.client.post(url, data, format='json', **self.header) def setUpTestData(cls):
self.assertHttpStatus(response, status.HTTP_201_CREATED) tags = (
self.assertEqual(Tag.objects.count(), 6) Tag(name='Tag 1', slug='tag-1'),
self.assertEqual(response.data[0]['name'], data[0]['name']) Tag(name='Tag 2', slug='tag-2'),
self.assertEqual(response.data[1]['name'], data[1]['name']) Tag(name='Tag 3', slug='tag-3'),
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_tag(self):
data = {
'name': 'Test Tag X',
'slug': 'test-tag-x',
}
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Tag.objects.count(), 3)
tag1 = Tag.objects.get(pk=response.data['id'])
self.assertEqual(tag1.name, data['name'])
self.assertEqual(tag1.slug, data['slug'])
def test_delete_tag(self):
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Tag.objects.count(), 2)
class ConfigContextTest(APITestCase):
def setUp(self):
super().setUp()
self.configcontext1 = ConfigContext.objects.create(
name='Test Config Context 1',
weight=100,
data={'foo': 123}
)
self.configcontext2 = ConfigContext.objects.create(
name='Test Config Context 2',
weight=200,
data={'bar': 456}
)
self.configcontext3 = ConfigContext.objects.create(
name='Test Config Context 3',
weight=300,
data={'baz': 789}
) )
Tag.objects.bulk_create(tags)
def test_get_configcontext(self):
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk}) class ConfigContextTest(APIViewTestCases.APIViewTestCase):
response = self.client.get(url, **self.header) model = ConfigContext
brief_fields = ['id', 'name', 'url']
self.assertEqual(response.data['name'], self.configcontext1.name) create_data = [
self.assertEqual(response.data['data'], self.configcontext1.data)
def test_list_configcontexts(self):
url = reverse('extras-api:configcontext-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_configcontext(self):
region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
role1 = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
role2 = DeviceRole.objects.create(name='Test Role 2', slug='test-role-2')
platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1')
platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2')
tenantgroup1 = TenantGroup(name='Test Tenant Group 1', slug='test-tenant-group-1')
tenantgroup1.save()
tenantgroup2 = TenantGroup(name='Test Tenant Group 2', slug='test-tenant-group-2')
tenantgroup2.save()
tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2')
tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
data = {
'name': 'Test Config Context 4',
'weight': 1000,
'regions': [region1.pk, region2.pk],
'sites': [site1.pk, site2.pk],
'roles': [role1.pk, role2.pk],
'platforms': [platform1.pk, platform2.pk],
'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk],
'tenants': [tenant1.pk, tenant2.pk],
'tags': [tag1.slug, tag2.slug],
'data': {'foo': 'XXX'}
}
url = reverse('extras-api:configcontext-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ConfigContext.objects.count(), 4)
configcontext4 = ConfigContext.objects.get(pk=response.data['id'])
self.assertEqual(configcontext4.name, data['name'])
self.assertEqual(region1.pk, data['regions'][0])
self.assertEqual(region2.pk, data['regions'][1])
self.assertEqual(site1.pk, data['sites'][0])
self.assertEqual(site2.pk, data['sites'][1])
self.assertEqual(role1.pk, data['roles'][0])
self.assertEqual(role2.pk, data['roles'][1])
self.assertEqual(platform1.pk, data['platforms'][0])
self.assertEqual(platform2.pk, data['platforms'][1])
self.assertEqual(tenantgroup1.pk, data['tenant_groups'][0])
self.assertEqual(tenantgroup2.pk, data['tenant_groups'][1])
self.assertEqual(tenant1.pk, data['tenants'][0])
self.assertEqual(tenant2.pk, data['tenants'][1])
self.assertEqual(tag1.slug, data['tags'][0])
self.assertEqual(tag2.slug, data['tags'][1])
self.assertEqual(configcontext4.data, data['data'])
def test_create_configcontext_bulk(self):
data = [
{ {
'name': 'Test Config Context 4', 'name': 'Config Context 4',
'data': {'more_foo': True}, 'data': {'more_foo': True},
}, },
{ {
'name': 'Test Config Context 5', 'name': 'Config Context 5',
'data': {'more_bar': False}, 'data': {'more_bar': False},
}, },
{ {
'name': 'Test Config Context 6', 'name': 'Config Context 6',
'data': {'more_baz': None}, 'data': {'more_baz': None},
}, },
] ]
url = reverse('extras-api:configcontext-list') @classmethod
response = self.client.post(url, data, format='json', **self.header) def setUpTestData(cls):
self.assertHttpStatus(response, status.HTTP_201_CREATED) config_contexts = (
self.assertEqual(ConfigContext.objects.count(), 6) ConfigContext(name='Config Context 1', weight=100, data={'foo': 123}),
for i in range(0, 3): ConfigContext(name='Config Context 2', weight=200, data={'bar': 456}),
self.assertEqual(response.data[i]['name'], data[i]['name']) ConfigContext(name='Config Context 3', weight=300, data={'baz': 789}),
self.assertEqual(response.data[i]['data'], data[i]['data']) )
ConfigContext.objects.bulk_create(config_contexts)
def test_update_configcontext(self):
region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
data = {
'name': 'Test Config Context X',
'weight': 999,
'regions': [region1.pk, region2.pk],
'data': {'foo': 'XXX'}
}
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ConfigContext.objects.count(), 3)
configcontext1 = ConfigContext.objects.get(pk=response.data['id'])
self.assertEqual(configcontext1.name, data['name'])
self.assertEqual(configcontext1.weight, data['weight'])
self.assertEqual(sorted([r.pk for r in configcontext1.regions.all()]), sorted(data['regions']))
self.assertEqual(configcontext1.data, data['data'])
def test_delete_configcontext(self):
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConfigContext.objects.count(), 2)
def test_render_configcontext_for_object(self): def test_render_configcontext_for_object(self):
"""
# Create a Device for which we'll render a config context Test rendering config context data for a device.
manufacturer = Manufacturer.objects.create( """
name='Test Manufacturer', manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
slug='test-manufacturer' devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
) devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_type = DeviceType.objects.create( site = Site.objects.create(name='Site-1', slug='site-1')
manufacturer=manufacturer, device = Device.objects.create(name='Device 1', device_type=devicetype, device_role=devicerole, site=site)
model='Test Device Type'
)
device_role = DeviceRole.objects.create(
name='Test Role',
slug='test-role'
)
site = Site.objects.create(
name='Test Site',
slug='test-site'
)
device = Device.objects.create(
name='Test Device',
device_type=device_type,
device_role=device_role,
site=site
)
# Test default config contexts (created at test setup) # Test default config contexts (created at test setup)
rendered_context = device.get_config_context() rendered_context = device.get_config_context()
@ -516,7 +175,7 @@ class ConfigContextTest(APITestCase):
# Add another context specific to the site # Add another context specific to the site
configcontext4 = ConfigContext( configcontext4 = ConfigContext(
name='Test Config Context 4', name='Config Context 4',
data={'site_data': 'ABC'} data={'site_data': 'ABC'}
) )
configcontext4.save() configcontext4.save()
@ -526,7 +185,7 @@ class ConfigContextTest(APITestCase):
# Override one of the default contexts # Override one of the default contexts
configcontext5 = ConfigContext( configcontext5 = ConfigContext(
name='Test Config Context 5', name='Config Context 5',
weight=2000, weight=2000,
data={'foo': 999} data={'foo': 999}
) )
@ -536,12 +195,9 @@ class ConfigContextTest(APITestCase):
self.assertEqual(rendered_context['foo'], 999) self.assertEqual(rendered_context['foo'], 999)
# Add a context which does NOT match our device and ensure it does not apply # Add a context which does NOT match our device and ensure it does not apply
site2 = Site.objects.create( site2 = Site.objects.create(name='Site 2', slug='site-2')
name='Test Site 2',
slug='test-site-2'
)
configcontext6 = ConfigContext( configcontext6 = ConfigContext(
name='Test Config Context 6', name='Config Context 6',
weight=2000, weight=2000,
data={'bar': 999} data={'bar': 999}
) )
@ -639,6 +295,7 @@ class CreatedUpdatedFilterTest(APITestCase):
) )
def test_get_rack_created(self): def test_get_rack_created(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list') url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created=2001-02-03'.format(url), **self.header) response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
@ -646,6 +303,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_created_gte(self): def test_get_rack_created_gte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list') url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header) response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
@ -653,6 +311,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk) self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
def test_get_rack_created_lte(self): def test_get_rack_created_lte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list') url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header) response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
@ -660,6 +319,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_last_updated(self): def test_get_rack_last_updated(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list') url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header) response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
@ -667,6 +327,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk) self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_last_updated_gte(self): def test_get_rack_last_updated_gte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list') url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header) response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
@ -674,6 +335,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk) self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
def test_get_rack_last_updated_lte(self): def test_get_rack_last_updated_lte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list') url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header) response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)

View File

@ -4,7 +4,6 @@ from rest_framework import status
from dcim.models import Site from dcim.models import Site
from extras.choices import * from extras.choices import *
from extras.constants import *
from extras.models import CustomField, CustomFieldValue, ObjectChange from extras.models import CustomField, CustomFieldValue, ObjectChange
from utilities.testing import APITestCase from utilities.testing import APITestCase
@ -12,7 +11,6 @@ from utilities.testing import APITestCase
class ChangeLogTest(APITestCase): class ChangeLogTest(APITestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
# Create a custom field on the Site model # Create a custom field on the Site model
@ -26,21 +24,17 @@ class ChangeLogTest(APITestCase):
cf.obj_type.set([ct]) cf.obj_type.set([ct])
def test_create_object(self): def test_create_object(self):
data = { data = {
'name': 'Test Site 1', 'name': 'Test Site 1',
'slug': 'test-site-1', 'slug': 'test-site-1',
'custom_fields': { 'custom_fields': {
'my_field': 'ABC' 'my_field': 'ABC'
}, },
'tags': [
'bar', 'foo'
],
} }
self.assertEqual(ObjectChange.objects.count(), 0) self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -52,10 +46,8 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, site) self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
def test_update_object(self): def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1') site = Site(name='Test Site 1', slug='test-site-1')
site.save() site.save()
@ -65,14 +57,11 @@ class ChangeLogTest(APITestCase):
'custom_fields': { 'custom_fields': {
'my_field': 'DEF' 'my_field': 'DEF'
}, },
'tags': [
'abc', 'xyz'
],
} }
self.assertEqual(ObjectChange.objects.count(), 0) self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.change_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.put(url, data, format='json', **self.header) response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
@ -84,27 +73,23 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, site) self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields']) self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
def test_delete_object(self): def test_delete_object(self):
site = Site( site = Site(
name='Test Site 1', name='Test Site 1',
slug='test-site-1' slug='test-site-1'
) )
site.save() site.save()
site.tags.add('foo', 'bar')
CustomFieldValue.objects.create( CustomFieldValue.objects.create(
field=CustomField.objects.get(name='my_field'), field=CustomField.objects.get(name='my_field'),
obj=site, obj=site,
value='ABC' value='ABC'
) )
self.assertEqual(ObjectChange.objects.count(), 0) self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.delete_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.delete(url, **self.header)
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Site.objects.count(), 0) self.assertEqual(Site.objects.count(), 0)
@ -113,4 +98,3 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'}) self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])

View File

@ -1,7 +1,6 @@
from datetime import date from datetime import date
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@ -9,7 +8,7 @@ from dcim.forms import SiteCSVForm
from dcim.models import Site from dcim.models import Site
from extras.choices import * from extras.choices import *
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
from utilities.testing import APITestCase, create_test_user from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -99,6 +98,19 @@ class CustomFieldTest(TestCase):
cf.delete() cf.delete()
class CustomFieldManagerTest(TestCase):
def setUp(self):
content_type = ContentType.objects.get_for_model(Site)
custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
custom_field.save()
custom_field.obj_type.set([content_type])
def test_get_for_model(self):
self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
class CustomFieldAPITest(APITestCase): class CustomFieldAPITest(APITestCase):
@classmethod @classmethod
@ -170,8 +182,9 @@ class CustomFieldAPITest(APITestCase):
Validate that custom fields are present on an object even if it has no values defined. Validate that custom fields are present on an object even if it has no values defined.
""" """
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk})
response = self.client.get(url, **self.header) self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[0].name) self.assertEqual(response.data['name'], self.sites[0].name)
self.assertEqual(response.data['custom_fields'], { self.assertEqual(response.data['custom_fields'], {
'text_field': None, 'text_field': None,
@ -189,10 +202,10 @@ class CustomFieldAPITest(APITestCase):
site2_cfvs = { site2_cfvs = {
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all() cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
} }
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
response = self.client.get(url, **self.header) self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[1].name) self.assertEqual(response.data['name'], self.sites[1].name)
self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
@ -209,8 +222,9 @@ class CustomFieldAPITest(APITestCase):
'name': 'Site 3', 'name': 'Site 3',
'slug': 'site-3', 'slug': 'site-3',
} }
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -251,8 +265,9 @@ class CustomFieldAPITest(APITestCase):
'choice_field': self.cf_select_choice2.pk, 'choice_field': self.cf_select_choice2.pk,
}, },
} }
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -297,8 +312,9 @@ class CustomFieldAPITest(APITestCase):
'slug': 'site-5', 'slug': 'site-5',
}, },
) )
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(data)) self.assertEqual(len(response.data), len(data))
@ -355,8 +371,9 @@ class CustomFieldAPITest(APITestCase):
'custom_fields': custom_field_data, 'custom_fields': custom_field_data,
}, },
) )
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(data)) self.assertEqual(len(response.data), len(data))
@ -398,8 +415,9 @@ class CustomFieldAPITest(APITestCase):
'number_field': 1234, 'number_field': 1234,
}, },
} }
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
@ -457,17 +475,10 @@ class CustomFieldChoiceAPITest(APITestCase):
class CustomFieldImportTest(TestCase): class CustomFieldImportTest(TestCase):
user_permissions = (
def setUp(self):
user = create_test_user(
permissions=[
'dcim.view_site', 'dcim.view_site',
'dcim.add_site', 'dcim.add_site',
]
) )
self.client = Client()
self.client.force_login(user)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -9,45 +9,53 @@ class TaggedItemTest(APITestCase):
""" """
Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH). Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
""" """
def setUp(self):
super().setUp()
def test_create_tagged_item(self): def test_create_tagged_item(self):
tags = self.create_tags("Foo", "Bar", "Baz")
data = { data = {
'name': 'Test Site', 'name': 'Test Site',
'slug': 'test-site', 'slug': 'test-site',
'tags': ['Foo', 'Bar', 'Baz'] 'tags': [t.pk for t in tags]
} }
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header) self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) self.assertListEqual(
sorted([t['id'] for t in response.data['tags']]),
sorted(data['tags'])
)
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()] self.assertListEqual(
self.assertEqual(sorted(tags), sorted(data['tags'])) sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "Baz"])
)
def test_update_tagged_item(self): def test_update_tagged_item(self):
site = Site.objects.create( site = Site.objects.create(
name='Test Site', name='Test Site',
slug='test-site' slug='test-site'
) )
site.tags.add('Foo', 'Bar', 'Baz') site.tags.add("Foo", "Bar", "Baz")
self.create_tags("New Tag")
data = { data = {
'tags': ['Foo', 'Bar', 'New Tag'] 'tags': [
{"name": "Foo"},
{"name": "Bar"},
{"name": "New Tag"},
]
} }
self.add_permissions('dcim.change_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.patch(url, data, format='json', **self.header)
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags'])) self.assertListEqual(
sorted([t['name'] for t in response.data['tags']]),
sorted([t['name'] for t in data['tags']])
)
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()] self.assertListEqual(
self.assertEqual(sorted(tags), sorted(data['tags'])) sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "New Tag"])
)

View File

@ -13,10 +13,6 @@ from utilities.testing import ViewTestCases, TestCase
class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase): class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Tag model = Tag
# Disable inapplicable tests
test_create_object = None
test_import_objects = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -33,21 +29,29 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'Some comments', 'comments': 'Some comments',
} }
cls.csv_data = (
"name,slug,color,description",
"Tag 4,tag-4,ff0000,Fourth tag",
"Tag 5,tag-5,00ff00,Fifth tag",
"Tag 6,tag-6,0000ff,Sixth tag",
)
cls.bulk_edit_data = { cls.bulk_edit_data = {
'color': '00ff00', 'color': '00ff00',
} }
class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase): # TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by absence of standard create/edit, bulk create views
class ConfigContextTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = ConfigContext model = ConfigContext
# Disable inapplicable tests
test_import_objects = None
# TODO: Resolve model discrepancies when creating/editing ConfigContexts
test_create_object = None
test_edit_object = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -42,13 +42,13 @@ class WebhookTest(APITestCase):
webhook.obj_type.set([site_ct]) webhook.obj_type.set([site_ct])
def test_enqueue_webhook_create(self): def test_enqueue_webhook_create(self):
# Create an object via the REST API # Create an object via the REST API
data = { data = {
'name': 'Test Site', 'name': 'Test Site',
'slug': 'test-site', 'slug': 'test-site',
} }
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Site.objects.count(), 1) self.assertEqual(Site.objects.count(), 1)
@ -62,14 +62,13 @@ class WebhookTest(APITestCase):
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE) self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE)
def test_enqueue_webhook_update(self): def test_enqueue_webhook_update(self):
site = Site.objects.create(name='Site 1', slug='site-1')
# Update an object via the REST API # Update an object via the REST API
site = Site.objects.create(name='Site 1', slug='site-1')
data = { data = {
'comments': 'Updated the site', 'comments': 'Updated the site',
} }
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
@ -82,11 +81,10 @@ class WebhookTest(APITestCase):
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE)
def test_enqueue_webhook_delete(self): def test_enqueue_webhook_delete(self):
site = Site.objects.create(name='Site 1', slug='site-1')
# Delete an object via the REST API # Delete an object via the REST API
site = Site.objects.create(name='Site 1', slug='site-1')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
self.add_permissions('dcim.delete_site')
response = self.client.delete(url, **self.header) response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)

View File

@ -9,6 +9,8 @@ urlpatterns = [
# Tags # Tags
path('tags/', views.TagListView.as_view(), name='tag_list'), path('tags/', views.TagListView.as_view(), name='tag_list'),
path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<str:slug>/', views.TagView.as_view(), name='tag'), path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
@ -18,7 +20,7 @@ urlpatterns = [
# Config contexts # Config contexts
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'), path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'), path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),

View File

@ -22,6 +22,22 @@ def is_taggable(obj):
return False return False
def image_upload(instance, filename):
"""
Return a path for uploading image attchments.
"""
path = 'image-attachments/'
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
extension = filename.rsplit('.')[-1].lower()
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
filename = '.'.join([instance.name, extension])
elif instance.name:
filename = instance.name
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
@deconstructible @deconstructible
class FeatureQuery: class FeatureQuery:
""" """

View File

@ -1,7 +1,6 @@
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q from django.db.models import Count, Q
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseForbidden
@ -13,37 +12,37 @@ from django_tables2 import RequestConfig
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict from utilities.utils import shallow_compare_dict
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView from utilities.views import (
from . import filters, forms BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
ObjectPermissionRequiredMixin,
)
from . import filters, forms, tables
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports from .reports import get_report, get_reports
from .scripts import get_scripts, run_script from .scripts import get_scripts, run_script
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
# #
# Tags # Tags
# #
class TagListView(PermissionRequiredMixin, ObjectListView): class TagListView(ObjectListView):
permission_required = 'extras.view_tag' queryset = Tag.restricted.annotate(
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True) items=Count('extras_taggeditem_items', distinct=True)
).order_by( ).order_by(
'name' 'name'
) )
filterset = filters.TagFilterSet filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm filterset_form = forms.TagFilterForm
table = TagTable table = tables.TagTable
action_buttons = ()
class TagView(PermissionRequiredMixin, View): class TagView(ObjectView):
permission_required = 'extras.view_tag' queryset = Tag.restricted.all()
def get(self, request, slug): def get(self, request, slug):
tag = get_object_or_404(Tag, slug=slug) tag = get_object_or_404(self.queryset, slug=slug)
tagged_items = TaggedItem.objects.filter( tagged_items = TaggedItem.objects.filter(
tag=tag tag=tag
).prefetch_related( ).prefetch_related(
@ -51,7 +50,7 @@ class TagView(PermissionRequiredMixin, View):
) )
# Generate a table of all items tagged with this Tag # Generate a table of all items tagged with this Tag
items_table = TaggedItemTable(tagged_items) items_table = tables.TaggedItemTable(tagged_items)
paginate = { paginate = {
'paginator_class': EnhancedPaginator, 'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
@ -65,40 +64,43 @@ class TagView(PermissionRequiredMixin, View):
}) })
class TagEditView(PermissionRequiredMixin, ObjectEditView): class TagEditView(ObjectEditView):
permission_required = 'extras.change_tag' queryset = Tag.restricted.all()
queryset = Tag.objects.all()
model_form = forms.TagForm model_form = forms.TagForm
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
template_name = 'extras/tag_edit.html' template_name = 'extras/tag_edit.html'
class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): class TagDeleteView(ObjectDeleteView):
permission_required = 'extras.delete_tag' queryset = Tag.restricted.all()
queryset = Tag.objects.all()
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
class TagBulkEditView(PermissionRequiredMixin, BulkEditView): class TagBulkImportView(BulkImportView):
permission_required = 'extras.change_tag' queryset = Tag.restricted.all()
queryset = Tag.objects.annotate( model_form = forms.TagCSVForm
table = tables.TagTable
default_return_url = 'extras:tag_list'
class TagBulkEditView(BulkEditView):
queryset = Tag.restricted.annotate(
items=Count('extras_taggeditem_items', distinct=True) items=Count('extras_taggeditem_items', distinct=True)
).order_by( ).order_by(
'name' 'name'
) )
table = TagTable table = tables.TagTable
form = forms.TagBulkEditForm form = forms.TagBulkEditForm
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class TagBulkDeleteView(BulkDeleteView):
permission_required = 'extras.delete_tag' queryset = Tag.restricted.annotate(
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items') items=Count('extras_taggeditem_items')
).order_by( ).order_by(
'name' 'name'
) )
table = TagTable table = tables.TagTable
default_return_url = 'extras:tag_list' default_return_url = 'extras:tag_list'
@ -106,27 +108,29 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Config contexts # Config contexts
# #
class ConfigContextListView(PermissionRequiredMixin, ObjectListView): class ConfigContextListView(ObjectListView):
permission_required = 'extras.view_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet filterset = filters.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm filterset_form = forms.ConfigContextFilterForm
table = ConfigContextTable table = tables.ConfigContextTable
action_buttons = ('add',) action_buttons = ('add',)
class ConfigContextView(PermissionRequiredMixin, View): class ConfigContextView(ObjectView):
permission_required = 'extras.view_configcontext' queryset = ConfigContext.objects.all()
def get(self, request, pk): def get(self, request, pk):
configcontext = get_object_or_404(ConfigContext, pk=pk) configcontext = get_object_or_404(self.queryset, pk=pk)
# Determine user's preferred output format # Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']: if request.GET.get('format') in ['json', 'yaml']:
format = request.GET.get('format') format = request.GET.get('format')
if request.user.is_authenticated:
request.user.config.set('extras.configcontext.format', format, commit=True) request.user.config.set('extras.configcontext.format', format, commit=True)
else: elif request.user.is_authenticated:
format = request.user.config.get('extras.configcontext.format', 'json') format = request.user.config.get('extras.configcontext.format', 'json')
else:
format = 'json'
return render(request, 'extras/configcontext.html', { return render(request, 'extras/configcontext.html', {
'configcontext': configcontext, 'configcontext': configcontext,
@ -134,56 +138,50 @@ class ConfigContextView(PermissionRequiredMixin, View):
}) })
class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView): class ConfigContextEditView(ObjectEditView):
permission_required = 'extras.add_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
model_form = forms.ConfigContextForm model_form = forms.ConfigContextForm
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
template_name = 'extras/configcontext_edit.html' template_name = 'extras/configcontext_edit.html'
class ConfigContextEditView(ConfigContextCreateView): class ConfigContextBulkEditView(BulkEditView):
permission_required = 'extras.change_configcontext'
class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet filterset = filters.ConfigContextFilterSet
table = ConfigContextTable table = tables.ConfigContextTable
form = forms.ConfigContextBulkEditForm form = forms.ConfigContextBulkEditForm
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ConfigContextDeleteView(ObjectDeleteView):
permission_required = 'extras.delete_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ConfigContextBulkDeleteView(BulkDeleteView):
permission_required = 'extras.delete_configcontext'
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
table = ConfigContextTable table = tables.ConfigContextTable
default_return_url = 'extras:configcontext_list' default_return_url = 'extras:configcontext_list'
class ObjectConfigContextView(View): class ObjectConfigContextView(ObjectView):
object_class = None
base_template = None base_template = None
def get(self, request, pk): def get(self, request, pk):
obj = get_object_or_404(self.object_class, pk=pk) obj = get_object_or_404(self.queryset, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj) source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(obj)
model_name = self.object_class._meta.model_name model_name = self.queryset.model._meta.model_name
# Determine user's preferred output format # Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']: if request.GET.get('format') in ['json', 'yaml']:
format = request.GET.get('format') format = request.GET.get('format')
if request.user.is_authenticated:
request.user.config.set('extras.configcontext.format', format, commit=True) request.user.config.set('extras.configcontext.format', format, commit=True)
else: elif request.user.is_authenticated:
format = request.user.config.get('extras.configcontext.format', 'json') format = request.user.config.get('extras.configcontext.format', 'json')
else:
format = 'json'
return render(request, 'extras/object_configcontext.html', { return render(request, 'extras/object_configcontext.html', {
model_name: obj, model_name: obj,
@ -200,30 +198,33 @@ class ObjectConfigContextView(View):
# Change logging # Change logging
# #
class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): class ObjectChangeListView(ObjectListView):
permission_required = 'extras.view_objectchange'
queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type') queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
filterset = filters.ObjectChangeFilterSet filterset = filters.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = ObjectChangeTable table = tables.ObjectChangeTable
template_name = 'extras/objectchange_list.html' template_name = 'extras/objectchange_list.html'
action_buttons = ('export',) action_buttons = ('export',)
class ObjectChangeView(PermissionRequiredMixin, View): class ObjectChangeView(ObjectView):
permission_required = 'extras.view_objectchange' queryset = ObjectChange.objects.all()
def get(self, request, pk): def get(self, request, pk):
objectchange = get_object_or_404(ObjectChange, pk=pk) objectchange = get_object_or_404(self.queryset, pk=pk)
related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk) related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
related_changes_table = ObjectChangeTable( request_id=objectchange.request_id
).exclude(
pk=objectchange.pk
)
related_changes_table = tables.ObjectChangeTable(
data=related_changes[:50], data=related_changes[:50],
orderable=False orderable=False
) )
objectchanges = ObjectChange.objects.filter( objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
changed_object_type=objectchange.changed_object_type, changed_object_type=objectchange.changed_object_type,
changed_object_id=objectchange.changed_object_id, changed_object_id=objectchange.changed_object_id,
) )
@ -261,17 +262,18 @@ class ObjectChangeLogView(View):
def get(self, request, model, **kwargs): def get(self, request, model, **kwargs):
# Get object my model and kwargs (e.g. slug='foo') # Get object my model and kwargs (e.g. slug='foo')
obj = get_object_or_404(model, **kwargs) queryset = model.objects.restrict(request.user, 'view')
obj = get_object_or_404(queryset, **kwargs)
# Gather all changes for this object (and its related objects) # Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model) content_type = ContentType.objects.get_for_model(model)
objectchanges = ObjectChange.objects.prefetch_related( objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
'user', 'changed_object_type' 'user', 'changed_object_type'
).filter( ).filter(
Q(changed_object_type=content_type, changed_object_id=obj.pk) | Q(changed_object_type=content_type, changed_object_id=obj.pk) |
Q(related_object_type=content_type, related_object_id=obj.pk) Q(related_object_type=content_type, related_object_id=obj.pk)
) )
objectchanges_table = ObjectChangeTable( objectchanges_table = tables.ObjectChangeTable(
data=objectchanges, data=objectchanges,
orderable=False orderable=False
) )
@ -304,8 +306,7 @@ class ObjectChangeLogView(View):
# Image attachments # Image attachments
# #
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView): class ImageAttachmentEditView(ObjectEditView):
permission_required = 'extras.change_imageattachment'
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
model_form = forms.ImageAttachmentForm model_form = forms.ImageAttachmentForm
@ -320,8 +321,7 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
return imageattachment.parent.get_absolute_url() return imageattachment.parent.get_absolute_url()
class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ImageAttachmentDeleteView(ObjectDeleteView):
permission_required = 'extras.delete_imageattachment'
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
def get_return_url(self, request, imageattachment): def get_return_url(self, request, imageattachment):
@ -332,11 +332,12 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
# Reports # Reports
# #
class ReportListView(PermissionRequiredMixin, View): class ReportListView(ObjectPermissionRequiredMixin, View):
""" """
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each. Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
""" """
permission_required = 'extras.view_reportresult' def get_required_permission(self):
return 'extras.view_reportresult'
def get(self, request): def get(self, request):
@ -356,11 +357,12 @@ class ReportListView(PermissionRequiredMixin, View):
}) })
class ReportView(PermissionRequiredMixin, View): class ReportView(ObjectPermissionRequiredMixin, View):
""" """
Display a single Report and its associated ReportResult (if any). Display a single Report and its associated ReportResult (if any).
""" """
permission_required = 'extras.view_reportresult' def get_required_permission(self):
return 'extras.view_reportresult'
def get(self, request, name): def get(self, request, name):
@ -379,11 +381,12 @@ class ReportView(PermissionRequiredMixin, View):
}) })
class ReportRunView(PermissionRequiredMixin, View): class ReportRunView(ObjectPermissionRequiredMixin, View):
""" """
Run a Report and record a new ReportResult. Run a Report and record a new ReportResult.
""" """
permission_required = 'extras.add_reportresult' def get_required_permission(self):
return 'extras.add_reportresult'
def post(self, request, name): def post(self, request, name):
@ -409,8 +412,10 @@ class ReportRunView(PermissionRequiredMixin, View):
# Scripts # Scripts
# #
class ScriptListView(PermissionRequiredMixin, View): class ScriptListView(ObjectPermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def get_required_permission(self):
return 'extras.view_script'
def get(self, request): def get(self, request):
@ -419,8 +424,10 @@ class ScriptListView(PermissionRequiredMixin, View):
}) })
class ScriptView(PermissionRequiredMixin, View): class ScriptView(ObjectPermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def get_required_permission(self):
return 'extras.view_script'
def _get_script(self, module, name): def _get_script(self, module, name):
scripts = get_scripts() scripts = get_scripts()
@ -430,7 +437,6 @@ class ScriptView(PermissionRequiredMixin, View):
raise Http404 raise Http404
def get(self, request, module, name): def get(self, request, module, name):
script = self._get_script(module, name) script = self._get_script(module, name)
form = script.as_form(initial=request.GET) form = script.as_form(initial=request.GET)

View File

@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF from ipam import models
from utilities.api import WritableNestedSerializer from utilities.api import WritableNestedSerializer
__all__ = [ __all__ = [
@ -9,6 +9,7 @@ __all__ = [
'NestedPrefixSerializer', 'NestedPrefixSerializer',
'NestedRIRSerializer', 'NestedRIRSerializer',
'NestedRoleSerializer', 'NestedRoleSerializer',
'NestedServiceSerializer',
'NestedVLANGroupSerializer', 'NestedVLANGroupSerializer',
'NestedVLANSerializer', 'NestedVLANSerializer',
'NestedVRFSerializer', 'NestedVRFSerializer',
@ -24,7 +25,7 @@ class NestedVRFSerializer(WritableNestedSerializer):
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VRF model = models.VRF
fields = ['id', 'url', 'name', 'rd', 'prefix_count'] fields = ['id', 'url', 'name', 'rd', 'prefix_count']
@ -37,7 +38,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
aggregate_count = serializers.IntegerField(read_only=True) aggregate_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = RIR model = models.RIR
fields = ['id', 'url', 'name', 'slug', 'aggregate_count'] fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
@ -45,7 +46,7 @@ class NestedAggregateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
class Meta: class Meta:
model = Aggregate model = models.Aggregate
fields = ['id', 'url', 'family', 'prefix'] fields = ['id', 'url', 'family', 'prefix']
@ -59,7 +60,7 @@ class NestedRoleSerializer(WritableNestedSerializer):
vlan_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Role model = models.Role
fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count'] fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count']
@ -68,7 +69,7 @@ class NestedVLANGroupSerializer(WritableNestedSerializer):
vlan_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VLANGroup model = models.VLANGroup
fields = ['id', 'url', 'name', 'slug', 'vlan_count'] fields = ['id', 'url', 'name', 'slug', 'vlan_count']
@ -76,7 +77,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta: class Meta:
model = VLAN model = models.VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name'] fields = ['id', 'url', 'vid', 'name', 'display_name']
@ -88,7 +89,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
class Meta: class Meta:
model = Prefix model = models.Prefix
fields = ['id', 'url', 'family', 'prefix'] fields = ['id', 'url', 'family', 'prefix']
@ -96,10 +97,21 @@ class NestedPrefixSerializer(WritableNestedSerializer):
# IP addresses # IP addresses
# #
class NestedIPAddressSerializer(WritableNestedSerializer): class NestedIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta: class Meta:
model = IPAddress model = models.IPAddress
fields = ['id', 'url', 'family', 'address'] fields = ['id', 'url', 'family', 'address']
#
# Services
#
class NestedServiceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
class Meta:
model = models.Service
fields = ['id', 'url', 'name', 'protocol', 'port']

View File

@ -1,18 +1,22 @@
from collections import OrderedDict from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers from rest_framework import serializers
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from dcim.models import Interface from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.choices import * from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ( from utilities.api import (
ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
get_serializer_for_model,
) )
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
from .nested_serializers import * from .nested_serializers import *
@ -22,9 +26,8 @@ from .nested_serializers import *
# VRFs # VRFs
# #
class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
ipaddress_count = serializers.IntegerField(read_only=True) ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
@ -48,10 +51,9 @@ class RIRSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count'] fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
rir = NestedRIRSerializer() rir = NestedRIRSerializer()
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Aggregate model = Aggregate
@ -98,13 +100,12 @@ class VLANGroupSerializer(ValidatedModelSerializer):
return data return data
class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False) status = ChoiceField(choices=VLANStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
prefix_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
@ -133,7 +134,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
# Prefixes # Prefixes
# #
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
@ -141,7 +142,6 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
vlan = NestedVLANSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True)
status = ChoiceField(choices=PrefixStatusChoices, required=False) status = ChoiceField(choices=PrefixStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True) role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Prefix model = Prefix
@ -226,25 +226,37 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer):
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request']) return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPAddressStatusChoices, required=False) status = ChoiceField(choices=IPAddressStatusChoices, required=False)
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True) assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
required=False
)
assigned_object = serializers.SerializerMethodField(read_only=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True) nat_outside = NestedIPAddressSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside', 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id',
'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields',
'created', 'last_updated',
] ]
read_only_fields = ['family'] read_only_fields = ['family']
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, obj):
if obj.assigned_object is None:
return None
serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.assigned_object, context=context).data
class AvailableIPSerializer(serializers.Serializer): class AvailableIPSerializer(serializers.Serializer):
""" """
@ -270,7 +282,7 @@ class AvailableIPSerializer(serializers.Serializer):
# Services # Services
# #
class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
device = NestedDeviceSerializer(required=False, allow_null=True) device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
@ -280,7 +292,6 @@ class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
required=False, required=False,
many=True many=True
) )
tags = TagListSerializerField(required=False)
class Meta: class Meta:
model = Service model = Service

View File

@ -5,7 +5,6 @@ from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
@ -74,12 +73,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
serializer_class = serializers.PrefixSerializer serializer_class = serializers.PrefixSerializer
filterset_class = filters.PrefixFilterSet filterset_class = filters.PrefixFilterSet
@swagger_auto_schema( @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
methods=['get', 'post'], @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
responses={
200: serializers.AvailablePrefixSerializer(many=True),
}
)
@action(detail=True, url_path='available-prefixes', methods=['get', 'post']) @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None): def available_prefixes(self, request, pk=None):
@ -94,10 +89,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
if request.method == 'POST': if request.method == 'POST':
# Permissions check
if not request.user.has_perm('ipam.add_prefix'):
raise PermissionDenied()
# Validate Requested Prefixes' length # Validate Requested Prefixes' length
serializer = serializers.PrefixLengthSerializer( serializer = serializers.PrefixLengthSerializer(
data=request.data if isinstance(request.data, list) else [request.data], data=request.data if isinstance(request.data, list) else [request.data],
@ -158,13 +149,10 @@ class PrefixViewSet(CustomFieldModelViewSet):
return Response(serializer.data) return Response(serializer.data)
@swagger_auto_schema( @swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
methods=['get', 'post'], @swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
responses={ request_body=serializers.AvailableIPSerializer(many=False))
200: serializers.AvailableIPSerializer(many=True), @action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
}
)
@action(detail=True, url_path='available-ips', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None): def available_ips(self, request, pk=None):
""" """
@ -180,10 +168,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
# Create the next available IP within the prefix # Create the next available IP within the prefix
if request.method == 'POST': if request.method == 'POST':
# Permissions check
if not request.user.has_perm('ipam.add_ipaddress'):
raise PermissionDenied()
# Normalize to a list of objects # Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data] requested_ips = request.data if isinstance(request.data, list) else [request.data]
@ -249,8 +233,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(CustomFieldModelViewSet): class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine', 'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
'nat_outside', 'tags',
) )
serializer_class = serializers.IPAddressSerializer serializer_class = serializers.IPAddressSerializer
filterset_class = filters.IPAddressFilterSet filterset_class = filters.IPAddressFilterSet
@ -276,7 +259,7 @@ class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related( queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags' 'site', 'group', 'tenant', 'role', 'tags'
).annotate( ).annotate(
prefix_count=get_subquery(Prefix, 'role') prefix_count=get_subquery(Prefix, 'vlan')
) )
serializer_class = serializers.VLANSerializer serializer_class = serializers.VLANSerializer
filterset_class = filters.VLANFilterSet filterset_class = filters.VLANFilterSet

View File

@ -1,3 +1,5 @@
from django.db.models import Q
from .choices import IPAddressRoleChoices from .choices import IPAddressRoleChoices
# BGP ASN bounds # BGP ASN bounds
@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6
# IPAddresses # IPAddresses
# #
IPADDRESS_ASSIGNMENT_MODELS = Q(
Q(app_label='dcim', model='interface') |
Q(app_label='virtualization', model='vminterface')
)
IPADDRESS_MASK_LENGTH_MIN = 1 IPADDRESS_MASK_LENGTH_MIN = 1
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6 IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6

View File

@ -11,7 +11,7 @@ from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter, BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter,
TreeNodeMultipleChoiceFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine, VMInterface
from .choices import * from .choices import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@ -71,12 +71,12 @@ class AggregateFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilt
label='Prefix', label='Prefix',
) )
rir_id = django_filters.ModelMultipleChoiceFilter( rir_id = django_filters.ModelMultipleChoiceFilter(
queryset=RIR.objects.all(), queryset=RIR.objects.unrestricted(),
label='RIR (ID)', label='RIR (ID)',
) )
rir = django_filters.ModelMultipleChoiceFilter( rir = django_filters.ModelMultipleChoiceFilter(
field_name='rir__slug', field_name='rir__slug',
queryset=RIR.objects.all(), queryset=RIR.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='RIR (slug)', label='RIR (slug)',
) )
@ -148,40 +148,40 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cre
label='Mask length', label='Mask length',
) )
vrf_id = django_filters.ModelMultipleChoiceFilter( vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(), queryset=VRF.objects.unrestricted(),
label='VRF', label='VRF',
) )
vrf = django_filters.ModelMultipleChoiceFilter( vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd', field_name='vrf__rd',
queryset=VRF.objects.all(), queryset=VRF.objects.unrestricted(),
to_field_name='rd', to_field_name='rd',
label='VRF (RD)', label='VRF (RD)',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
vlan_id = django_filters.ModelMultipleChoiceFilter( vlan_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLAN.objects.all(), queryset=VLAN.objects.unrestricted(),
label='VLAN (ID)', label='VLAN (ID)',
) )
vlan_vid = django_filters.NumberFilter( vlan_vid = django_filters.NumberFilter(
@ -189,12 +189,12 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Cre
label='VLAN number (1-4095)', label='VLAN number (1-4095)',
) )
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(), queryset=Role.objects.unrestricted(),
label='Role (ID)', label='Role (ID)',
) )
role = django_filters.ModelMultipleChoiceFilter( role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug', field_name='role__slug',
queryset=Role.objects.all(), queryset=Role.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
@ -290,12 +290,12 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
label='Mask length', label='Mask length',
) )
vrf_id = django_filters.ModelMultipleChoiceFilter( vrf_id = django_filters.ModelMultipleChoiceFilter(
queryset=VRF.objects.all(), queryset=VRF.objects.unrestricted(),
label='VRF', label='VRF',
) )
vrf = django_filters.ModelMultipleChoiceFilter( vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd', field_name='vrf__rd',
queryset=VRF.objects.all(), queryset=VRF.objects.unrestricted(),
to_field_name='rd', to_field_name='rd',
label='VRF (RD)', label='VRF (RD)',
) )
@ -309,27 +309,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
field_name='pk', field_name='pk',
label='Device (ID)', label='Device (ID)',
) )
virtual_machine_id = django_filters.ModelMultipleChoiceFilter( virtual_machine = MultiValueCharFilter(
field_name='interface__virtual_machine', method='filter_virtual_machine',
queryset=VirtualMachine.objects.all(), field_name='name',
label='Virtual machine (ID)',
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine__name',
queryset=VirtualMachine.objects.all(),
to_field_name='name',
label='Virtual machine (name)', label='Virtual machine (name)',
) )
virtual_machine_id = MultiValueNumberFilter(
method='filter_virtual_machine',
field_name='pk',
label='Virtual machine (ID)',
)
interface = django_filters.ModelMultipleChoiceFilter( interface = django_filters.ModelMultipleChoiceFilter(
field_name='interface__name', field_name='interface__name',
queryset=Interface.objects.all(), queryset=Interface.objects.unrestricted(),
to_field_name='name', to_field_name='name',
label='Interface (ID)', label='Interface (name)',
) )
interface_id = django_filters.ModelMultipleChoiceFilter( interface_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.all(), field_name='interface',
queryset=Interface.objects.unrestricted(),
label='Interface (ID)', label='Interface (ID)',
) )
vminterface = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface__name',
queryset=VMInterface.objects.unrestricted(),
to_field_name='name',
label='VM interface (name)',
)
vminterface_id = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface',
queryset=VMInterface.objects.unrestricted(),
label='VM interface (ID)',
)
assigned_to_interface = django_filters.BooleanFilter( assigned_to_interface = django_filters.BooleanFilter(
method='_assigned_to_interface', method='_assigned_to_interface',
label='Is assigned to an interface', label='Is assigned to an interface',
@ -379,40 +390,52 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
return queryset.filter(address__net_mask_length=value) return queryset.filter(address__net_mask_length=value)
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
try: devices = Device.objects.filter(**{'{}__in'.format(name): value})
devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value}) if not devices.exists():
vc_interface_ids = []
for device in devices:
vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none() return queryset.none()
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
return queryset.filter(
interface__in=interface_ids
)
def filter_virtual_machine(self, queryset, name, value):
virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
if not virtual_machines.exists():
return queryset.none()
interface_ids = []
for vm in virtual_machines:
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
return queryset.filter(
vminterface__in=interface_ids
)
def _assigned_to_interface(self, queryset, name, value): def _assigned_to_interface(self, queryset, name, value):
return queryset.exclude(interface__isnull=value) return queryset.exclude(assigned_object_id__isnull=value)
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
@ -428,45 +451,45 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, Creat
label='Search', label='Search',
) )
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
label='Region (ID)', label='Region (ID)',
) )
region = TreeNodeMultipleChoiceFilter( region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.unrestricted(),
field_name='site__region', field_name='site__region',
lookup_expr='in', lookup_expr='in',
to_field_name='slug', to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
label='Site (ID)', label='Site (ID)',
) )
site = django_filters.ModelMultipleChoiceFilter( site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', field_name='site__slug',
queryset=Site.objects.all(), queryset=Site.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
group_id = django_filters.ModelMultipleChoiceFilter( group_id = django_filters.ModelMultipleChoiceFilter(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.unrestricted(),
label='Group (ID)', label='Group (ID)',
) )
group = django_filters.ModelMultipleChoiceFilter( group = django_filters.ModelMultipleChoiceFilter(
field_name='group__slug', field_name='group__slug',
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Group', label='Group',
) )
role_id = django_filters.ModelMultipleChoiceFilter( role_id = django_filters.ModelMultipleChoiceFilter(
queryset=Role.objects.all(), queryset=Role.objects.unrestricted(),
label='Role (ID)', label='Role (ID)',
) )
role = django_filters.ModelMultipleChoiceFilter( role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug', field_name='role__slug',
queryset=Role.objects.all(), queryset=Role.objects.unrestricted(),
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
@ -497,22 +520,22 @@ class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
label='Search', label='Search',
) )
device_id = django_filters.ModelMultipleChoiceFilter( device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(), queryset=Device.objects.unrestricted(),
label='Device (ID)', label='Device (ID)',
) )
device = django_filters.ModelMultipleChoiceFilter( device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name', field_name='device__name',
queryset=Device.objects.all(), queryset=Device.objects.unrestricted(),
to_field_name='name', to_field_name='name',
label='Device (name)', label='Device (name)',
) )
virtual_machine_id = django_filters.ModelMultipleChoiceFilter( virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.unrestricted(),
label='Virtual machine (ID)', label='Virtual machine (ID)',
) )
virtual_machine = django_filters.ModelMultipleChoiceFilter( virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__name', field_name='virtual_machine__name',
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.unrestricted(),
to_field_name='name', to_field_name='name',
label='Virtual machine (name)', label='Virtual machine (name)',
) )

View File

@ -1,11 +1,11 @@
from django import forms from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from taggit.forms import TagField
from dcim.models import Device, Interface, Rack, Region, Site from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import ( from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
) )
from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
@ -14,7 +14,7 @@ from utilities.forms import (
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine, VMInterface
from .choices import * from .choices import *
from .constants import * from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@ -33,7 +33,8 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
# #
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -141,7 +142,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
rir = DynamicModelChoiceField( rir = DynamicModelChoiceField(
queryset=RIR.objects.all() queryset=RIR.objects.all()
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -292,7 +294,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False
) )
tags = TagField(required=False) tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = Prefix model = Prefix
@ -517,10 +522,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
# #
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm): class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
interface = forms.ModelChoiceField( device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
widget=APISelect(
filter_for={
'interface': 'device_id'
}
)
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False required=False
) )
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
widget=APISelect(
filter_for={
'vminterface': 'virtual_machine_id'
}
)
)
vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
label='Interface'
)
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
@ -584,15 +612,16 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
required=False, required=False,
label='Make this the primary IP for the device/VM' label='Make this the primary IP for the device/VM'
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent', 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', 'nat_inside', 'tenant_group', 'tenant', 'tags',
] ]
widgets = { widgets = {
'status': StaticSelect2(), 'status': StaticSelect2(),
@ -604,7 +633,14 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
# Initialize helper selectors # Initialize helper selectors
instance = kwargs.get('instance') instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy() initial = kwargs.get('initial', {}).copy()
if instance and instance.nat_inside and instance.nat_inside.device is not None: if instance:
if type(instance.assigned_object) is Interface:
initial['device'] = instance.assigned_object.device
initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface:
initial['virtual_machine'] = instance.assigned_object.virtual_machine
initial['vminterface'] = instance.assigned_object
if instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device initial['nat_device'] = instance.nat_inside.device
@ -614,17 +650,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
self.fields['vrf'].empty_label = 'Global' self.fields['vrf'].empty_label = 'Global'
# Limit interface selections to those belonging to the parent device/VM
if self.instance and self.instance.interface:
self.fields['interface'].queryset = Interface.objects.filter(
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
)
else:
self.fields['interface'].choices = []
# Initialize primary_for_parent if IP address is already assigned # Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.interface is not None: if self.instance.pk and self.instance.assigned_object:
parent = self.instance.interface.parent parent = self.instance.assigned_object.parent
if ( if (
self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
@ -634,32 +662,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
def clean(self): def clean(self):
super().clean() super().clean()
# Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
# Primary IP assignment is only available if an interface has been assigned. # Primary IP assignment is only available if an interface has been assigned.
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'): interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if self.cleaned_data.get('primary_for_parent') and not interface:
self.add_error( self.add_error(
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Set assigned object
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if interface:
self.instance.assigned_object = interface
ipaddress = super().save(*args, **kwargs) ipaddress = super().save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']: if interface and self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4: if ipaddress.address.version == 4:
parent.primary_ip4 = ipaddress interface.parent.primary_ip4 = ipaddress
else: else:
parent.primary_ip6 = ipaddress interface.primary_ip6 = ipaddress
parent.save() interface.parent.save()
elif self.cleaned_data['interface']: elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
parent = self.cleaned_data['interface'].parent interface.parent.primary_ip4 = None
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: interface.parent.save()
parent.primary_ip4 = None elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
parent.save() interface.parent.primary_ip4 = None
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: interface.parent.save()
parent.primary_ip6 = None
parent.save()
return ipaddress return ipaddress
@ -676,11 +711,15 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False, required=False,
label='VRF' label='VRF'
) )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
] ]
widgets = { widgets = {
'status': StaticSelect2(), 'status': StaticSelect2(),
@ -727,7 +766,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
help_text='Parent VM of assigned interface (if any)' help_text='Parent VM of assigned interface (if any)'
) )
interface = CSVModelChoiceField( interface = CSVModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.none(), # Can also refer to VMInterface
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text='Assigned interface' help_text='Assigned interface'
@ -746,21 +785,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
if data: if data:
# Limit interface queryset by assigned device or virtual machine # Limit interface queryset by assigned device
if data.get('device'): if data.get('device'):
params = { self.fields['interface'].queryset = Interface.objects.filter(
f"device__{self.fields['device'].to_field_name}": data.get('device') **{f"device__{self.fields['device'].to_field_name}": data['device']}
} )
# Limit interface queryset by assigned device
elif data.get('virtual_machine'): elif data.get('virtual_machine'):
params = { self.fields['interface'].queryset = VMInterface.objects.filter(
f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine') **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
} )
else:
params = {
'device': None,
'virtual_machine': None,
}
self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
def clean(self): def clean(self):
super().clean() super().clean()
@ -775,17 +810,9 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Set interface # Set interface assignment
if self.cleaned_data['device'] and self.cleaned_data['interface_name']: if self.cleaned_data['interface']:
self.instance.interface = Interface.objects.get( self.instance.assigned_object = self.cleaned_data['interface']
device=self.cleaned_data['device'],
name=self.cleaned_data['interface_name']
)
elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']:
self.instance.interface = Interface.objects.get(
virtual_machine=self.cleaned_data['virtual_machine'],
name=self.cleaned_data['interface_name']
)
ipaddress = super().save(*args, **kwargs) ipaddress = super().save(*args, **kwargs)
@ -997,7 +1024,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False
) )
tags = TagField(required=False) tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta: class Meta:
model = VLAN model = VLAN
@ -1164,7 +1194,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
min_value=SERVICE_PORT_MIN, min_value=SERVICE_PORT_MIN,
max_value=SERVICE_PORT_MAX max_value=SERVICE_PORT_MAX
) )
tags = TagField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False required=False
) )
@ -1187,13 +1218,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
# Limit IP address choices to those assigned to interfaces of the parent device/VM # Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device: if self.instance.device:
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
self.fields['ipaddresses'].queryset = IPAddress.objects.filter( self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface_id__in=vc_interface_ids interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
) )
elif self.instance.virtual_machine: elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter( self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__virtual_machine=self.instance.virtual_machine vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
) )
else: else:
self.fields['ipaddresses'].choices = [] self.fields['ipaddresses'].choices = []

View File

@ -1,9 +1,10 @@
from django.db import models from django.db.models import Manager
from ipam.lookups import Host, Inet from ipam.lookups import Host, Inet
from utilities.querysets import RestrictedQuerySet
class IPAddressManager(models.Manager): class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)):
def get_queryset(self): def get_queryset(self):
""" """
@ -13,5 +14,4 @@ class IPAddressManager(models.Manager):
then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each
IP address as a /32 or /128. IP address as a /32 or /128.
""" """
qs = super().get_queryset() return super().get_queryset().order_by(Inet(Host('address')))
return qs.order_by(Inet(Host('address')))

View File

@ -0,0 +1,40 @@
from django.db import migrations, models
import django.db.models.deletion
def set_assigned_object_type(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
IPAddress = apps.get_model('ipam', 'IPAddress')
device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk
IPAddress.objects.update(assigned_object_type=device_ct)
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0036_standardize_description'),
]
operations = [
migrations.RenameField(
model_name='ipaddress',
old_name='interface',
new_name='assigned_object_id',
),
migrations.AlterField(
model_name='ipaddress',
name='assigned_object_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='ipaddress',
name='assigned_object_type',
field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType'),
preserve_default=False,
),
migrations.RunPython(
code=set_assigned_object_type
),
]

View File

@ -1,10 +1,11 @@
import netaddr import netaddr
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import F, Q from django.db.models import F
from django.urls import reverse from django.urls import reverse
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -12,8 +13,9 @@ from dcim.models import Device, Interface
from extras.models import CustomFieldModel, ObjectChange, TaggedItem from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features from extras.utils import extras_features
from utilities.models import ChangeLoggedModel from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object from utilities.utils import serialize_object
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine, VMInterface
from .choices import * from .choices import *
from .constants import * from .constants import *
from .fields import IPNetworkField, IPAddressField from .fields import IPNetworkField, IPAddressField
@ -74,9 +76,10 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
clone_fields = [ clone_fields = [
'tenant', 'enforce_unique', 'description', 'tenant', 'enforce_unique', 'description',
@ -131,6 +134,8 @@ class RIR(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'is_private', 'description'] csv_headers = ['name', 'slug', 'is_private', 'description']
class Meta: class Meta:
@ -179,9 +184,10 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['prefix', 'rir', 'date_added', 'description'] csv_headers = ['prefix', 'rir', 'date_added', 'description']
clone_fields = [ clone_fields = [
'rir', 'date_added', 'description', 'rir', 'date_added', 'description',
@ -274,6 +280,8 @@ class Role(ChangeLoggedModel):
blank=True, blank=True,
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'weight', 'description'] csv_headers = ['name', 'slug', 'weight', 'description']
class Meta: class Meta:
@ -360,9 +368,9 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem)
objects = PrefixQuerySet.as_manager() objects = PrefixQuerySet.as_manager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [ csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description', 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description',
@ -599,13 +607,22 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
blank=True, blank=True,
help_text='The functional role of this IP' help_text='The functional role of this IP'
) )
interface = models.ForeignKey( assigned_object_type = models.ForeignKey(
to='dcim.Interface', to=ContentType,
on_delete=models.CASCADE, limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
related_name='ip_addresses', on_delete=models.PROTECT,
related_name='+',
blank=True, blank=True,
null=True null=True
) )
assigned_object_id = models.PositiveIntegerField(
blank=True,
null=True
)
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
nat_inside = models.OneToOneField( nat_inside = models.OneToOneField(
to='self', to='self',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -631,12 +648,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
objects = IPAddressManager()
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = IPAddressManager()
csv_headers = [ csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'is_primary',
'dns_name', 'description', 'dns_name', 'description',
] ]
clone_fields = [ clone_fields = [
@ -700,32 +717,31 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
) )
}) })
if self.pk:
# Check for primary IP assignment that doesn't match the assigned device/VM # Check for primary IP assignment that doesn't match the assigned device/VM
if self.pk and type(self.assigned_object) is Interface:
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if device: if device:
if self.interface is None: if self.assigned_object is None:
raise ValidationError({ raise ValidationError({
'interface': "IP address is primary for device {} but not assigned".format(device) 'interface': f"IP address is primary for device {device} but not assigned to an interface"
}) })
elif (device.primary_ip4 == self or device.primary_ip6 == self) and self.interface.device != device: elif self.assigned_object.device != device:
raise ValidationError({ raise ValidationError({
'interface': "IP address is primary for device {} but assigned to {} ({})".format( 'interface': f"IP address is primary for device {device} but assigned to "
device, self.interface.device, self.interface f"{self.assigned_object.device} ({self.assigned_object})"
)
}) })
elif self.pk and type(self.assigned_object) is VMInterface:
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if vm: if vm:
if self.interface is None: if self.assigned_object is None:
raise ValidationError({ raise ValidationError({
'interface': "IP address is primary for virtual machine {} but not assigned".format(vm) 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
f"interface"
}) })
elif (vm.primary_ip4 == self or vm.primary_ip6 == self) and self.interface.virtual_machine != vm: elif self.interface.virtual_machine != vm:
raise ValidationError({ raise ValidationError({
'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format( 'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
vm, self.interface.virtual_machine, self.interface f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
)
}) })
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@ -736,29 +752,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def to_objectchange(self, action): def to_objectchange(self, action):
# Annotate the assigned Interface (if any) # Annotate the assigned object, if any
try:
parent_obj = self.interface
except ObjectDoesNotExist:
parent_obj = None
return ObjectChange( return ObjectChange(
changed_object=self, changed_object=self,
object_repr=str(self), object_repr=str(self),
action=action, action=action,
related_object=parent_obj, related_object=self.assigned_object,
object_data=serialize_object(self) object_data=serialize_object(self)
) )
def to_csv(self): def to_csv(self):
# Determine if this IP is primary for a Device # Determine if this IP is primary for a Device
is_primary = False
if self.address.version == 4 and getattr(self, 'primary_ip4_for', False): if self.address.version == 4 and getattr(self, 'primary_ip4_for', False):
is_primary = True is_primary = True
elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False): elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True is_primary = True
else:
is_primary = False obj_type = None
if self.assigned_object_type:
obj_type = f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}'
return ( return (
self.address, self.address,
@ -766,9 +780,8 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.tenant.name if self.tenant else None, self.tenant.name if self.tenant else None,
self.get_status_display(), self.get_status_display(),
self.get_role_display(), self.get_role_display(),
self.device.identifier if self.device else None, obj_type,
self.virtual_machine.name if self.virtual_machine else None, self.assigned_object_id,
self.interface.name if self.interface else None,
is_primary, is_primary,
self.dns_name, self.dns_name,
self.description, self.description,
@ -789,18 +802,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.address.prefixlen = value self.address.prefixlen = value
mask_length = property(fset=_set_mask_length) mask_length = property(fset=_set_mask_length)
@property
def device(self):
if self.interface:
return self.interface.device
return None
@property
def virtual_machine(self):
if self.interface:
return self.interface.virtual_machine
return None
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status) return self.STATUS_CLASS_MAP.get(self.status)
@ -828,6 +829,8 @@ class VLANGroup(ChangeLoggedModel):
blank=True blank=True
) )
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'site', 'description'] csv_headers = ['name', 'slug', 'site', 'description']
class Meta: class Meta:
@ -923,9 +926,10 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
clone_fields = [ clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'description', 'site', 'group', 'tenant', 'status', 'role', 'description',
@ -1039,9 +1043,10 @@ class Service(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description']
class Meta: class Meta:

View File

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

View File

@ -92,14 +92,6 @@ IPADDRESS_ASSIGN_LINK = """
{% endif %} {% endif %}
""" """
IPADDRESS_PARENT = """
{% if record.interface %}
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
{% else %}
&mdash;
{% endif %}
"""
VRF_LINK = """ VRF_LINK = """
{% if record.vrf %} {% if record.vrf %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a> <a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
@ -168,7 +160,7 @@ VLAN_MEMBER_UNTAGGED = """
VLAN_MEMBER_ACTIONS = """ VLAN_MEMBER_ACTIONS = """
{% if perms.dcim.change_interface %} {% if perms.dcim.change_interface %}
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a> <a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:vminterface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
{% endif %} {% endif %}
""" """
@ -378,6 +370,8 @@ class PrefixTable(BaseTable):
verbose_name='Pool' verbose_name='Pool'
) )
add_prefetch = False
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Prefix model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description') fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description')
@ -429,18 +423,14 @@ class IPAddressTable(BaseTable):
tenant = tables.TemplateColumn( tenant = tables.TemplateColumn(
template_code=TENANT_LINK template_code=TENANT_LINK
) )
parent = tables.TemplateColumn( assigned = tables.BooleanColumn(
template_code=IPADDRESS_PARENT, accessor='assigned_object_id'
orderable=False
)
interface = tables.Column(
orderable=False
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ( fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
) )
row_attrs = { row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@ -463,11 +453,11 @@ class IPAddressDetailTable(IPAddressTable):
class Meta(IPAddressTable.Meta): class Meta(IPAddressTable.Meta):
fields = ( fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name', 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name',
'description', 'tags', 'description', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
) )
@ -479,17 +469,13 @@ class IPAddressAssignTable(BaseTable):
status = tables.TemplateColumn( status = tables.TemplateColumn(
template_code=STATUS_LABEL template_code=STATUS_LABEL
) )
parent = tables.TemplateColumn( assigned_object = tables.Column(
template_code=IPADDRESS_PARENT,
orderable=False
)
interface = tables.Column(
orderable=False orderable=False
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description') fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
orderable = False orderable = False
@ -665,6 +651,9 @@ class ServiceTable(BaseTable):
viewname='ipam:service', viewname='ipam:service',
args=[Accessor('pk')] args=[Accessor('pk')]
) )
parent = tables.LinkColumn(
order_by=('device', 'virtual_machine')
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:service_list' url_name='ipam:service_list'
) )

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
from ipam.choices import * from ipam.choices import *
from ipam.filters import * from ipam.filters import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from virtualization.models import Cluster, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase):
) )
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
interfaces = (
Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'),
Interface(device=devices[2], name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1') cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase):
) )
VirtualMachine.objects.bulk_create(virtual_machines) VirtualMachine.objects.bulk_create(virtual_machines)
interfaces = ( vminterfaces = (
Interface(device=devices[0], name='Interface 1'), VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'), VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'),
Interface(device=devices[2], name='Interface 3'), VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'),
Interface(virtual_machine=virtual_machines[0], name='Interface 1'),
Interface(virtual_machine=virtual_machines[1], name='Interface 2'),
Interface(virtual_machine=virtual_machines[2], name='Interface 3'),
) )
Interface.objects.bulk_create(interfaces) VMInterface.objects.bulk_create(vminterfaces)
tenant_groups = ( tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
@ -411,16 +415,16 @@ class IPAddressTestCase(TestCase):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
ipaddresses = ( ipaddresses = (
IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE), IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'), IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'), IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'), IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'), IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE), IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
) )
IPAddress.objects.bulk_create(ipaddresses) IPAddress.objects.bulk_create(ipaddresses)
@ -487,7 +491,14 @@ class IPAddressTestCase(TestCase):
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'interface': ['Interface 1', 'Interface 2']} params = {'interface': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vminterface(self):
vminterfaces = VMInterface.objects.all()[:2]
params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vminterface': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned_to_interface(self): def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'} params = {'assigned_to_interface': 'true'}

View File

@ -5,6 +5,7 @@ from netaddr import IPNetwork
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from ipam.choices import * from ipam.choices import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.models import Tenant
from utilities.testing import ViewTestCases from utilities.testing import ViewTestCases
@ -14,19 +15,27 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
tenants = (
Tenant(name='Tenant A', slug='tenant-a'),
Tenant(name='Tenant B', slug='tenant-b'),
)
Tenant.objects.bulk_create(tenants)
VRF.objects.bulk_create([ VRF.objects.bulk_create([
VRF(name='VRF 1', rd='65000:1'), VRF(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'), VRF(name='VRF 2', rd='65000:2'),
VRF(name='VRF 3', rd='65000:3'), VRF(name='VRF 3', rd='65000:3'),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'name': 'VRF X', 'name': 'VRF X',
'rd': '65000:999', 'rd': '65000:999',
'tenant': None, 'tenant': tenants[0].pk,
'enforce_unique': True, 'enforce_unique': True,
'description': 'A new VRF', 'description': 'A new VRF',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -37,7 +46,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'tenant': None, 'tenant': tenants[1].pk,
'enforce_unique': False, 'enforce_unique': False,
'description': 'New description', 'description': 'New description',
} }
@ -88,12 +97,14 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]), Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'prefix': IPNetwork('10.99.0.0/16'), 'prefix': IPNetwork('10.99.0.0/16'),
'rir': rirs[1].pk, 'rir': rirs[1].pk,
'date_added': datetime.date(2020, 1, 1), 'date_added': datetime.date(2020, 1, 1),
'description': 'A new aggregate', 'description': 'A new aggregate',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -166,6 +177,8 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'prefix': IPNetwork('192.0.2.0/24'), 'prefix': IPNetwork('192.0.2.0/24'),
'site': sites[1].pk, 'site': sites[1].pk,
@ -176,7 +189,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'role': roles[1].pk, 'role': roles[1].pk,
'is_pool': True, 'is_pool': True,
'description': 'A new prefix', 'description': 'A new prefix',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -215,17 +228,18 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]), IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'vrf': vrfs[1].pk, 'vrf': vrfs[1].pk,
'address': IPNetwork('192.0.2.99/24'), 'address': IPNetwork('192.0.2.99/24'),
'tenant': None, 'tenant': None,
'status': IPAddressStatusChoices.STATUS_RESERVED, 'status': IPAddressStatusChoices.STATUS_RESERVED,
'role': IPAddressRoleChoices.ROLE_ANYCAST, 'role': IPAddressRoleChoices.ROLE_ANYCAST,
'interface': None,
'nat_inside': None, 'nat_inside': None,
'dns_name': 'example', 'dns_name': 'example',
'description': 'A new IP address', 'description': 'A new IP address',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -304,6 +318,8 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]), VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'site': sites[1].pk, 'site': sites[1].pk,
'group': vlangroups[1].pk, 'group': vlangroups[1].pk,
@ -313,7 +329,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'status': VLANStatusChoices.STATUS_RESERVED, 'status': VLANStatusChoices.STATUS_RESERVED,
'role': roles[1].pk, 'role': roles[1].pk,
'description': 'A new VLAN', 'description': 'A new VLAN',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
@ -333,12 +349,19 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): # TODO: Update base class to PrimaryObjectViewTestCase
# Blocked by absence of standard creation view
class ServiceTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = Service model = Service
# TODO: Resolve URL for Service creation
test_create_object = None
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -354,6 +377,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103), Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'device': device.pk,
'virtual_machine': None, 'virtual_machine': None,
@ -362,7 +387,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'port': 999, 'port': 999,
'ipaddresses': [], 'ipaddresses': [],
'description': 'A new service', 'description': 'A new service',
'tags': 'Alpha,Bravo,Charlie', 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (

View File

@ -9,7 +9,7 @@ urlpatterns = [
# VRFs # VRFs
path('vrfs/', views.VRFListView.as_view(), name='vrf_list'), path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'),
path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
@ -20,7 +20,7 @@ urlpatterns = [
# RIRs # RIRs
path('rirs/', views.RIRListView.as_view(), name='rir_list'), path('rirs/', views.RIRListView.as_view(), name='rir_list'),
path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), path('rirs/add/', views.RIREditView.as_view(), name='rir_add'),
path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'), path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
@ -28,7 +28,7 @@ urlpatterns = [
# Aggregates # Aggregates
path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), path('aggregates/add/', views.AggregateEditView.as_view(), name='aggregate_add'),
path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
@ -39,7 +39,7 @@ urlpatterns = [
# Roles # Roles
path('roles/', views.RoleListView.as_view(), name='role_list'), path('roles/', views.RoleListView.as_view(), name='role_list'),
path('roles/add/', views.RoleCreateView.as_view(), name='role_add'), path('roles/add/', views.RoleEditView.as_view(), name='role_add'),
path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'), path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
@ -47,7 +47,7 @@ urlpatterns = [
# Prefixes # Prefixes
path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), path('prefixes/add/', views.PrefixEditView.as_view(), name='prefix_add'),
path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
@ -60,7 +60,7 @@ urlpatterns = [
# IP addresses # IP addresses
path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), path('ip-addresses/add/', views.IPAddressEditView.as_view(), name='ipaddress_add'),
path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
@ -73,7 +73,7 @@ urlpatterns = [
# VLAN groups # VLAN groups
path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
@ -82,7 +82,7 @@ urlpatterns = [
# VLANs # VLANs
path('vlans/', views.VLANListView.as_view(), name='vlan_list'), path('vlans/', views.VLANListView.as_view(), name='vlan_list'),
path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), path('vlans/add/', views.VLANEditView.as_view(), name='vlan_add'),
path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),

View File

@ -1,18 +1,17 @@
import netaddr import netaddr
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count
from django.db.models import Count, Q
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from dcim.models import Device, Interface from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
ObjectListView,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine, VMInterface
from . import filters, forms, tables from . import filters, forms, tables
from .choices import * from .choices import *
from .constants import * from .constants import *
@ -112,21 +111,20 @@ def add_available_vlans(vlan_group, vlans):
# VRFs # VRFs
# #
class VRFListView(PermissionRequiredMixin, ObjectListView): class VRFListView(ObjectListView):
permission_required = 'ipam.view_vrf'
queryset = VRF.objects.prefetch_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet filterset = filters.VRFFilterSet
filterset_form = forms.VRFFilterForm filterset_form = forms.VRFFilterForm
table = tables.VRFTable table = tables.VRFTable
class VRFView(PermissionRequiredMixin, View): class VRFView(ObjectView):
permission_required = 'ipam.view_vrf' queryset = VRF.objects.all()
def get(self, request, pk): def get(self, request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk) vrf = get_object_or_404(self.queryset, pk=pk)
prefix_count = Prefix.objects.filter(vrf=vrf).count() prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count()
return render(request, 'ipam/vrf.html', { return render(request, 'ipam/vrf.html', {
'vrf': vrf, 'vrf': vrf,
@ -134,33 +132,26 @@ class VRFView(PermissionRequiredMixin, View):
}) })
class VRFCreateView(PermissionRequiredMixin, ObjectEditView): class VRFEditView(ObjectEditView):
permission_required = 'ipam.add_vrf'
queryset = VRF.objects.all() queryset = VRF.objects.all()
model_form = forms.VRFForm model_form = forms.VRFForm
template_name = 'ipam/vrf_edit.html' template_name = 'ipam/vrf_edit.html'
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFEditView(VRFCreateView): class VRFDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_vrf'
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf'
queryset = VRF.objects.all() queryset = VRF.objects.all()
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): class VRFBulkImportView(BulkImportView):
permission_required = 'ipam.add_vrf' queryset = VRF.objects.all()
model_form = forms.VRFCSVForm model_form = forms.VRFCSVForm
table = tables.VRFTable table = tables.VRFTable
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): class VRFBulkEditView(BulkEditView):
permission_required = 'ipam.change_vrf'
queryset = VRF.objects.prefetch_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet filterset = filters.VRFFilterSet
table = tables.VRFTable table = tables.VRFTable
@ -168,8 +159,7 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VRFBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_vrf'
queryset = VRF.objects.prefetch_related('tenant') queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet filterset = filters.VRFFilterSet
table = tables.VRFTable table = tables.VRFTable
@ -180,8 +170,7 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# RIRs # RIRs
# #
class RIRListView(PermissionRequiredMixin, ObjectListView): class RIRListView(ObjectListView):
permission_required = 'ipam.view_rir'
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filterset = filters.RIRFilterSet filterset = filters.RIRFilterSet
filterset_form = forms.RIRFilterForm filterset_form = forms.RIRFilterForm
@ -257,26 +246,20 @@ class RIRListView(PermissionRequiredMixin, ObjectListView):
return rirs return rirs
class RIRCreateView(PermissionRequiredMixin, ObjectEditView): class RIREditView(ObjectEditView):
permission_required = 'ipam.add_rir'
queryset = RIR.objects.all() queryset = RIR.objects.all()
model_form = forms.RIRForm model_form = forms.RIRForm
default_return_url = 'ipam:rir_list' default_return_url = 'ipam:rir_list'
class RIREditView(RIRCreateView): class RIRBulkImportView(BulkImportView):
permission_required = 'ipam.change_rir' queryset = RIR.objects.all()
class RIRBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_rir'
model_form = forms.RIRCSVForm model_form = forms.RIRCSVForm
table = tables.RIRTable table = tables.RIRTable
default_return_url = 'ipam:rir_list' default_return_url = 'ipam:rir_list'
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RIRBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_rir'
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filterset = filters.RIRFilterSet filterset = filters.RIRFilterSet
table = tables.RIRTable table = tables.RIRTable
@ -287,8 +270,7 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Aggregates # Aggregates
# #
class AggregateListView(PermissionRequiredMixin, ObjectListView): class AggregateListView(ObjectListView):
permission_required = 'ipam.view_aggregate'
queryset = Aggregate.objects.prefetch_related('rir').annotate( queryset = Aggregate.objects.prefetch_related('rir').annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ()) child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
) )
@ -314,15 +296,15 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView):
} }
class AggregateView(PermissionRequiredMixin, View): class AggregateView(ObjectView):
permission_required = 'ipam.view_aggregate' queryset = Aggregate.objects.all()
def get(self, request, pk): def get(self, request, pk):
aggregate = get_object_or_404(Aggregate, pk=pk) aggregate = get_object_or_404(self.queryset, pk=pk)
# Find all child prefixes contained by this aggregate # Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.filter( child_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
prefix__net_contained_or_equal=str(aggregate.prefix) prefix__net_contained_or_equal=str(aggregate.prefix)
).prefetch_related( ).prefetch_related(
'site', 'role' 'site', 'role'
@ -359,33 +341,26 @@ class AggregateView(PermissionRequiredMixin, View):
}) })
class AggregateCreateView(PermissionRequiredMixin, ObjectEditView): class AggregateEditView(ObjectEditView):
permission_required = 'ipam.add_aggregate'
queryset = Aggregate.objects.all() queryset = Aggregate.objects.all()
model_form = forms.AggregateForm model_form = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html' template_name = 'ipam/aggregate_edit.html'
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateEditView(AggregateCreateView): class AggregateDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_aggregate'
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate'
queryset = Aggregate.objects.all() queryset = Aggregate.objects.all()
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): class AggregateBulkImportView(BulkImportView):
permission_required = 'ipam.add_aggregate' queryset = Aggregate.objects.all()
model_form = forms.AggregateCSVForm model_form = forms.AggregateCSVForm
table = tables.AggregateTable table = tables.AggregateTable
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): class AggregateBulkEditView(BulkEditView):
permission_required = 'ipam.change_aggregate'
queryset = Aggregate.objects.prefetch_related('rir') queryset = Aggregate.objects.prefetch_related('rir')
filterset = filters.AggregateFilterSet filterset = filters.AggregateFilterSet
table = tables.AggregateTable table = tables.AggregateTable
@ -393,8 +368,7 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class AggregateBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_aggregate'
queryset = Aggregate.objects.prefetch_related('rir') queryset = Aggregate.objects.prefetch_related('rir')
filterset = filters.AggregateFilterSet filterset = filters.AggregateFilterSet
table = tables.AggregateTable table = tables.AggregateTable
@ -405,32 +379,25 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Prefix/VLAN roles # Prefix/VLAN roles
# #
class RoleListView(PermissionRequiredMixin, ObjectListView): class RoleListView(ObjectListView):
permission_required = 'ipam.view_role'
queryset = Role.objects.all() queryset = Role.objects.all()
table = tables.RoleTable table = tables.RoleTable
class RoleCreateView(PermissionRequiredMixin, ObjectEditView): class RoleEditView(ObjectEditView):
permission_required = 'ipam.add_role'
queryset = Role.objects.all() queryset = Role.objects.all()
model_form = forms.RoleForm model_form = forms.RoleForm
default_return_url = 'ipam:role_list' default_return_url = 'ipam:role_list'
class RoleEditView(RoleCreateView): class RoleBulkImportView(BulkImportView):
permission_required = 'ipam.change_role' queryset = Role.objects.all()
class RoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_role'
model_form = forms.RoleCSVForm model_form = forms.RoleCSVForm
table = tables.RoleTable table = tables.RoleTable
default_return_url = 'ipam:role_list' default_return_url = 'ipam:role_list'
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RoleBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_role'
queryset = Role.objects.all() queryset = Role.objects.all()
table = tables.RoleTable table = tables.RoleTable
default_return_url = 'ipam:role_list' default_return_url = 'ipam:role_list'
@ -440,8 +407,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Prefixes # Prefixes
# #
class PrefixListView(PermissionRequiredMixin, ObjectListView): class PrefixListView(ObjectListView):
permission_required = 'ipam.view_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet filterset = filters.PrefixFilterSet
filterset_form = forms.PrefixFilterForm filterset_form = forms.PrefixFilterForm
@ -454,22 +420,22 @@ class PrefixListView(PermissionRequiredMixin, ObjectListView):
return self.queryset.annotate_depth(limit=limit) return self.queryset.annotate_depth(limit=limit)
class PrefixView(PermissionRequiredMixin, View): class PrefixView(ObjectView):
permission_required = 'ipam.view_prefix' queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role')
def get(self, request, pk): def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.prefetch_related( prefix = get_object_or_404(self.queryset, pk=pk)
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
), pk=pk)
try: try:
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) aggregate = Aggregate.objects.restrict(request.user, 'view').get(
prefix__net_contains_or_equals=str(prefix.prefix)
)
except Aggregate.DoesNotExist: except Aggregate.DoesNotExist:
aggregate = None aggregate = None
# Parent prefixes table # Parent prefixes table
parent_prefixes = Prefix.objects.filter( parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
Q(vrf=prefix.vrf) | Q(vrf__isnull=True) Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
).filter( ).filter(
prefix__net_contains=str(prefix.prefix) prefix__net_contains=str(prefix.prefix)
@ -480,7 +446,7 @@ class PrefixView(PermissionRequiredMixin, View):
parent_prefix_table.exclude = ('vrf',) parent_prefix_table.exclude = ('vrf',)
# Duplicate prefixes table # Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter( duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
vrf=prefix.vrf, prefix=str(prefix.prefix) vrf=prefix.vrf, prefix=str(prefix.prefix)
).exclude( ).exclude(
pk=prefix.pk pk=prefix.pk
@ -498,15 +464,15 @@ class PrefixView(PermissionRequiredMixin, View):
}) })
class PrefixPrefixesView(PermissionRequiredMixin, View): class PrefixPrefixesView(ObjectView):
permission_required = 'ipam.view_prefix' queryset = Prefix.objects.all()
def get(self, request, pk): def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk) prefix = get_object_or_404(self.queryset, pk=pk)
# Child prefixes table # Child prefixes table
child_prefixes = prefix.get_child_prefixes().prefetch_related( child_prefixes = prefix.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
'site', 'vlan', 'role', 'site', 'vlan', 'role',
).annotate_depth(limit=0) ).annotate_depth(limit=0)
@ -542,16 +508,16 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
}) })
class PrefixIPAddressesView(PermissionRequiredMixin, View): class PrefixIPAddressesView(ObjectView):
permission_required = 'ipam.view_prefix' queryset = Prefix.objects.all()
def get(self, request, pk): def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk) prefix = get_object_or_404(self.queryset, pk=pk)
# Find all IPAddresses belonging to this Prefix # Find all IPAddresses belonging to this Prefix
ipaddresses = prefix.get_child_ips().prefetch_related( ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for' 'vrf', 'primary_ip4_for', 'primary_ip6_for'
) )
# Add available IP addresses to the table if requested # Add available IP addresses to the table if requested
@ -586,34 +552,27 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
}) })
class PrefixCreateView(PermissionRequiredMixin, ObjectEditView): class PrefixEditView(ObjectEditView):
permission_required = 'ipam.add_prefix'
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
model_form = forms.PrefixForm model_form = forms.PrefixForm
template_name = 'ipam/prefix_edit.html' template_name = 'ipam/prefix_edit.html'
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
class PrefixEditView(PrefixCreateView): class PrefixDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_prefix'
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html' template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): class PrefixBulkImportView(BulkImportView):
permission_required = 'ipam.add_prefix' queryset = Prefix.objects.all()
model_form = forms.PrefixCSVForm model_form = forms.PrefixCSVForm
table = tables.PrefixTable table = tables.PrefixTable
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): class PrefixBulkEditView(BulkEditView):
permission_required = 'ipam.change_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet filterset = filters.PrefixFilterSet
table = tables.PrefixTable table = tables.PrefixTable
@ -621,8 +580,7 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:prefix_list' default_return_url = 'ipam:prefix_list'
class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PrefixBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet filterset = filters.PrefixFilterSet
table = tables.PrefixTable table = tables.PrefixTable
@ -633,25 +591,24 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# IP addresses # IP addresses
# #
class IPAddressListView(PermissionRequiredMixin, ObjectListView): class IPAddressListView(ObjectListView):
permission_required = 'ipam.view_ipaddress'
queryset = IPAddress.objects.prefetch_related( queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine' 'vrf__tenant', 'tenant', 'nat_inside'
) )
filterset = filters.IPAddressFilterSet filterset = filters.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressDetailTable table = tables.IPAddressDetailTable
class IPAddressView(PermissionRequiredMixin, View): class IPAddressView(ObjectView):
permission_required = 'ipam.view_ipaddress' queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
def get(self, request, pk): def get(self, request, pk):
ipaddress = get_object_or_404(IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), pk=pk) ipaddress = get_object_or_404(self.queryset, pk=pk)
# Parent prefixes table # Parent prefixes table
parent_prefixes = Prefix.objects.filter( parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip) vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
).prefetch_related( ).prefetch_related(
'site', 'role' 'site', 'role'
@ -660,12 +617,12 @@ class IPAddressView(PermissionRequiredMixin, View):
parent_prefixes_table.exclude = ('vrf',) parent_prefixes_table.exclude = ('vrf',)
# Duplicate IPs table # Duplicate IPs table
duplicate_ips = IPAddress.objects.filter( duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
vrf=ipaddress.vrf, address=str(ipaddress.address) vrf=ipaddress.vrf, address=str(ipaddress.address)
).exclude( ).exclude(
pk=ipaddress.pk pk=ipaddress.pk
).prefetch_related( ).prefetch_related(
'nat_inside', 'interface__device' 'nat_inside'
) )
# Exclude anycast IPs if this IP is anycast # Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST: if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
@ -673,14 +630,11 @@ class IPAddressView(PermissionRequiredMixin, View):
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Related IP table # Related IP table
related_ips = IPAddress.objects.prefetch_related( related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
'interface__device'
).exclude(
address=str(ipaddress.address) address=str(ipaddress.address)
).filter( ).filter(
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
) )
related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
paginate = { paginate = {
@ -697,8 +651,7 @@ class IPAddressView(PermissionRequiredMixin, View):
}) })
class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): class IPAddressEditView(ObjectEditView):
permission_required = 'ipam.add_ipaddress'
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
model_form = forms.IPAddressForm model_form = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html' template_name = 'ipam/ipaddress_edit.html'
@ -706,25 +659,26 @@ class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
def alter_obj(self, obj, request, url_args, url_kwargs): def alter_obj(self, obj, request, url_args, url_kwargs):
interface_id = request.GET.get('interface') if 'interface' in request.GET:
if interface_id:
try: try:
obj.interface = Interface.objects.get(pk=interface_id) obj.assigned_object = Interface.objects.get(pk=request.GET['interface'])
except (ValueError, Interface.DoesNotExist): except (ValueError, Interface.DoesNotExist):
pass pass
elif 'vminterface' in request.GET:
try:
obj.assigned_object = VMInterface.objects.get(pk=request.GET['vminterface'])
except (ValueError, VMInterface.DoesNotExist):
pass
return obj return obj
class IPAddressEditView(IPAddressCreateView): class IPAddressAssignView(ObjectView):
permission_required = 'ipam.change_ipaddress'
class IPAddressAssignView(PermissionRequiredMixin, View):
""" """
Search for IPAddresses to be assigned to an Interface. Search for IPAddresses to be assigned to an Interface.
""" """
permission_required = 'ipam.change_ipaddress' queryset = IPAddress.objects.all()
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@ -735,7 +689,6 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, request): def get(self, request):
form = forms.IPAddressAssignForm() form = forms.IPAddressAssignForm()
return render(request, 'ipam/ipaddress_assign.html', { return render(request, 'ipam/ipaddress_assign.html', {
@ -744,15 +697,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
}) })
def post(self, request): def post(self, request):
form = forms.IPAddressAssignForm(request.POST) form = forms.IPAddressAssignForm(request.POST)
table = None table = None
if form.is_valid(): if form.is_valid():
addresses = IPAddress.objects.prefetch_related( addresses = self.queryset.prefetch_related('vrf', 'tenant')
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
)
# Limit to 100 results # Limit to 100 results
addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100] addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100]
table = tables.IPAddressAssignTable(addresses) table = tables.IPAddressAssignTable(addresses)
@ -764,14 +714,13 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
}) })
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): class IPAddressDeleteView(ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress'
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): class IPAddressBulkCreateView(BulkCreateView):
permission_required = 'ipam.add_ipaddress' queryset = IPAddress.objects.all()
form = forms.IPAddressBulkCreateForm form = forms.IPAddressBulkCreateForm
model_form = forms.IPAddressBulkAddForm model_form = forms.IPAddressBulkAddForm
pattern_target = 'address' pattern_target = 'address'
@ -779,25 +728,23 @@ class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): class IPAddressBulkImportView(BulkImportView):
permission_required = 'ipam.add_ipaddress' queryset = IPAddress.objects.all()
model_form = forms.IPAddressCSVForm model_form = forms.IPAddressCSVForm
table = tables.IPAddressTable table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): class IPAddressBulkEditView(BulkEditView):
permission_required = 'ipam.change_ipaddress' queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filterset = filters.IPAddressFilterSet filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable table = tables.IPAddressTable
form = forms.IPAddressBulkEditForm form = forms.IPAddressBulkEditForm
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class IPAddressBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_ipaddress' queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filterset = filters.IPAddressFilterSet filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
@ -807,48 +754,40 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# VLAN groups # VLAN groups
# #
class VLANGroupListView(PermissionRequiredMixin, ObjectListView): class VLANGroupListView(ObjectListView):
permission_required = 'ipam.view_vlangroup'
queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): class VLANGroupEditView(ObjectEditView):
permission_required = 'ipam.add_vlangroup'
queryset = VLANGroup.objects.all() queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupForm model_form = forms.VLANGroupForm
default_return_url = 'ipam:vlangroup_list' default_return_url = 'ipam:vlangroup_list'
class VLANGroupEditView(VLANGroupCreateView): class VLANGroupBulkImportView(BulkImportView):
permission_required = 'ipam.change_vlangroup' queryset = VLANGroup.objects.all()
class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlangroup'
model_form = forms.VLANGroupCSVForm model_form = forms.VLANGroupCSVForm
table = tables.VLANGroupTable table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list' default_return_url = 'ipam:vlangroup_list'
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VLANGroupBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans')) queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filterset = filters.VLANGroupFilterSet filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list' default_return_url = 'ipam:vlangroup_list'
class VLANGroupVLANsView(PermissionRequiredMixin, View): class VLANGroupVLANsView(ObjectView):
permission_required = 'ipam.view_vlangroup' queryset = VLANGroup.objects.all()
def get(self, request, pk): def get(self, request, pk):
vlan_group = get_object_or_404(self.queryset, pk=pk)
vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk) vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk)
vlans = VLAN.objects.filter(group_id=pk)
vlans = add_available_vlans(vlan_group, vlans) vlans = add_available_vlans(vlan_group, vlans)
vlan_table = tables.VLANDetailTable(vlans) vlan_table = tables.VLANDetailTable(vlans)
@ -882,23 +821,22 @@ class VLANGroupVLANsView(PermissionRequiredMixin, View):
# VLANs # VLANs
# #
class VLANListView(PermissionRequiredMixin, ObjectListView): class VLANListView(ObjectListView):
permission_required = 'ipam.view_vlan'
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
filterset = filters.VLANFilterSet filterset = filters.VLANFilterSet
filterset_form = forms.VLANFilterForm filterset_form = forms.VLANFilterForm
table = tables.VLANDetailTable table = tables.VLANDetailTable
class VLANView(PermissionRequiredMixin, View): class VLANView(ObjectView):
permission_required = 'ipam.view_vlan' queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role')
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(VLAN.objects.prefetch_related( vlan = get_object_or_404(self.queryset, pk=pk)
'site__region', 'tenant__group', 'role' prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=vlan).prefetch_related(
), pk=pk) 'vrf', 'site', 'role'
prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role') )
prefix_table = tables.PrefixTable(list(prefixes), orderable=False) prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
prefix_table.exclude = ('vlan',) prefix_table.exclude = ('vlan',)
@ -908,13 +846,13 @@ class VLANView(PermissionRequiredMixin, View):
}) })
class VLANMembersView(PermissionRequiredMixin, View): class VLANMembersView(ObjectView):
permission_required = 'ipam.view_vlan' queryset = VLAN.objects.all()
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(VLAN.objects.all(), pk=pk) vlan = get_object_or_404(self.queryset, pk=pk)
members = vlan.get_members().prefetch_related('device', 'virtual_machine') members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members) members_table = tables.VLANMemberTable(members)
@ -931,33 +869,26 @@ class VLANMembersView(PermissionRequiredMixin, View):
}) })
class VLANCreateView(PermissionRequiredMixin, ObjectEditView): class VLANEditView(ObjectEditView):
permission_required = 'ipam.add_vlan'
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
model_form = forms.VLANForm model_form = forms.VLANForm
template_name = 'ipam/vlan_edit.html' template_name = 'ipam/vlan_edit.html'
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANEditView(VLANCreateView): class VLANDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_vlan'
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan'
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANBulkImportView(BulkImportView):
permission_required = 'ipam.add_vlan' queryset = VLAN.objects.all()
model_form = forms.VLANCSVForm model_form = forms.VLANCSVForm
table = tables.VLANTable table = tables.VLANTable
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): class VLANBulkEditView(BulkEditView):
permission_required = 'ipam.change_vlan'
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.VLANFilterSet filterset = filters.VLANFilterSet
table = tables.VLANTable table = tables.VLANTable
@ -965,8 +896,7 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VLANBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_vlan'
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.VLANFilterSet filterset = filters.VLANFilterSet
table = tables.VLANTable table = tables.VLANTable
@ -977,8 +907,7 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Services # Services
# #
class ServiceListView(PermissionRequiredMixin, ObjectListView): class ServiceListView(ObjectListView):
permission_required = 'ipam.view_service'
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet filterset = filters.ServiceFilterSet
filterset_form = forms.ServiceFilterForm filterset_form = forms.ServiceFilterForm
@ -986,20 +915,19 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView):
action_buttons = ('export',) action_buttons = ('export',)
class ServiceView(PermissionRequiredMixin, View): class ServiceView(ObjectView):
permission_required = 'ipam.view_service' queryset = Service.objects.all()
def get(self, request, pk): def get(self, request, pk):
service = get_object_or_404(Service, pk=pk) service = get_object_or_404(self.queryset, pk=pk)
return render(request, 'ipam/service.html', { return render(request, 'ipam/service.html', {
'service': service, 'service': service,
}) })
class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): class ServiceEditView(ObjectEditView):
permission_required = 'ipam.add_service'
queryset = Service.objects.all() queryset = Service.objects.all()
model_form = forms.ServiceForm model_form = forms.ServiceForm
template_name = 'ipam/service_edit.html' template_name = 'ipam/service_edit.html'
@ -1015,24 +943,18 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
return service.parent.get_absolute_url() return service.parent.get_absolute_url()
class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView): class ServiceBulkImportView(BulkImportView):
permission_required = 'ipam.add_service' queryset = Service.objects.all()
model_form = forms.ServiceCSVForm model_form = forms.ServiceCSVForm
table = tables.ServiceTable table = tables.ServiceTable
default_return_url = 'ipam:service_list' default_return_url = 'ipam:service_list'
class ServiceEditView(ServiceCreateView): class ServiceDeleteView(ObjectDeleteView):
permission_required = 'ipam.change_service'
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
queryset = Service.objects.all() queryset = Service.objects.all()
class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): class ServiceBulkEditView(BulkEditView):
permission_required = 'ipam.change_service'
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet filterset = filters.ServiceFilterSet
table = tables.ServiceTable table = tables.ServiceTable
@ -1040,8 +962,7 @@ class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:service_list' default_return_url = 'ipam:service_list'
class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ServiceBulkDeleteView(BulkDeleteView):
permission_required = 'ipam.delete_service'
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet filterset = filters.ServiceFilterSet
table = tables.ServiceTable table = tables.ServiceTable

View File

@ -2,7 +2,7 @@ from django.conf import settings
from django.db.models import QuerySet from django.db.models import QuerySet
from rest_framework import authentication, exceptions from rest_framework import authentication, exceptions
from rest_framework.pagination import LimitOffsetPagination from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.utils import formatting from rest_framework.utils import formatting
@ -51,7 +51,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
return token.user, token return token.user, token
class TokenPermissions(DjangoModelPermissions): class TokenPermissions(DjangoObjectPermissions):
""" """
Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
for unsafe requests (POST/PUT/PATCH/DELETE). for unsafe requests (POST/PUT/PATCH/DELETE).
@ -74,15 +74,29 @@ class TokenPermissions(DjangoModelPermissions):
super().__init__() super().__init__()
def _verify_write_permission(self, request):
# If token authentication is in use, verify that the token allows write operations (for unsafe methods).
if request.method in SAFE_METHODS:
return True
if isinstance(request.auth, Token) and request.auth.write_enabled:
return True
def has_permission(self, request, view): def has_permission(self, request, view):
# If token authentication is in use, verify that the token allows write operations (for unsafe methods). # Enforce Token write ability
if request.method not in SAFE_METHODS and isinstance(request.auth, Token): if not self._verify_write_permission(request):
if not request.auth.write_enabled:
return False return False
return super().has_permission(request, view) return super().has_permission(request, view)
def has_object_permission(self, request, view, obj):
# Enforce Token write ability
if not self._verify_write_permission(request):
return False
return super().has_object_permission(request, view, obj)
# #
# Pagination # Pagination

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