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.
-->
### Steps to Reproduce
1. Disable any installed plugins by commenting out the `PLUGINS` setting in
`configuration.py`.
1.
2.
3.

1
.gitignore vendored
View File

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

View File

@ -42,10 +42,6 @@ django-tables2
# https://github.com/alex/django-taggit
django-taggit
# A Django REST Framework serializer which represents tags
# https://github.com/glemmaPaul/django-taggit-serializer
django-taggit-serializer
# A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/
django-timezone-field

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
```
{% 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."

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
```
#### 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.constants import CONNECTION_STATUS_PLANNED
from dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report
@ -53,7 +52,7 @@ class DeviceConnectionsReport(Report):
console_port.device,
"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(
console_port.device,
"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):
if power_port.connected_endpoint is not None:
connected_ports += 1
if power_port.connection_status == CONNECTION_STATUS_PLANNED:
if not power_port.connection_status:
self.log_warning(
device,
"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.
## 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.
{!docs/models/users/token.md!}
## 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.
## 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_BOTTOM
@ -86,7 +94,12 @@ CORS_ORIGIN_WHITELIST = [
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
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)
* PORT - TCP port to use for the connection (default: 25)
* USERNAME - Username with which to authenticate
* PASSSWORD - Password with which to authenticate
* TIMEOUT - Amount of time to wait for a connection (seconds)
* FROM_EMAIL - Sender address for emails sent by NetBox
* `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`)
* `USERNAME` - Username with which to authenticate
* `PASSSWORD` - Password with which to authenticate
* `USE_SSL` - Use SSL when connecting to the server (default: `False`). Mutually exclusive with `USE_TLS`.
* `USE_TLS` - Use TLS when connecting to the server (default: `False`). Mutually exclusive with `USE_SSL`.
* `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
@ -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
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
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
Default: `True`
Default: `False`
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
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
### ObjectView
Retrieve and display a single object.
### ObjectListView
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 |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
| Database | PostgreSQL 9.4+ |
| Database | PostgreSQL 9.6+ |
| Task queuing | Redis/django-rq |
| 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).
!!! 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.
@ -51,7 +51,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
```no-highlight
# sudo -u postgres psql
psql (9.4.5)
psql (10.10)
Type "help" for help.
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.
```
# adduser --system --group netbox
# groupadd --system netbox
# adduser --system --gid netbox netbox
# 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
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
First, enable the LDAP authentication backend in `configuration.py`. (Be sure to overwrite this definition if it is already set to `RemoteUserBackend`.)
```python
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
```
Next, create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
### General Server Configuration
@ -145,7 +151,8 @@ logfile = "/opt/netbox/logs/django-ldap-debug.log"
my_logger = logging.getLogger('django_auth_ldap')
my_logger.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(
logfile, maxBytes=1024 * 500, backupCount=5)
logfile, maxBytes=1024 * 500, backupCount=5
)
my_logger.addHandler(handler)
```

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
## 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)
### Enhancements

View File

@ -1,7 +1,43 @@
# Netbox v2.9
# NetBox v2.9
## 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
* [#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
* [#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'
- Developing Plugins: 'plugins/development.md'
- Administration:
- Permissions: 'administration/permissions.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'
- API:

View File

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

View File

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

View File

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

View File

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

View File

@ -1,443 +1,189 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework import status
from circuits.choices import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Site
from extras.models import Graph
from utilities.testing import APITestCase
from utilities.testing import APITestCase, APIViewTestCases
class AppTest(APITestCase):
def test_root(self):
url = reverse('circuits-api:api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
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()
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.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3')
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)
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
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.graph1 = Graph.objects.create(
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})
self.add_permissions('circuits.view_provider')
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?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')
response = self.client.get(url, **self.header)
class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = CircuitType
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
create_data = (
{
'name': 'Circuit Type 4',
'slug': 'circuit-type-4',
},
{
'name': 'Circuit Type 5',
'slug': 'circuit-type-5',
},
{
'name': 'Circuit Type 6',
'slug': 'circuit-type-6',
},
)
self.assertEqual(response.data['count'], 3)
@classmethod
def setUpTestData(cls):
def test_list_providers_brief(self):
url = reverse('circuits-api:provider-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['circuit_count', 'id', 'name', 'slug', 'url']
circuit_types = (
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
)
CircuitType.objects.bulk_create(circuit_types)
def test_create_provider(self):
data = {
'name': 'Test Provider 4',
'slug': 'test-provider-4',
}
class CircuitTest(APIViewTestCases.APIViewTestCase):
model = Circuit
brief_fields = ['cid', 'id', 'url']
url = reverse('circuits-api:provider-list')
response = self.client.post(url, data, format='json', **self.header)
@classmethod
def setUpTestData(cls):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Provider.objects.count(), 4)
provider4 = Provider.objects.get(pk=response.data['id'])
self.assertEqual(provider4.name, data['name'])
self.assertEqual(provider4.slug, data['slug'])
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
)
Provider.objects.bulk_create(providers)
def test_create_provider_bulk(self):
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)
data = [
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',
'slug': 'test-provider-4',
'cid': 'Circuit 4',
'provider': providers[1].pk,
'type': circuit_types[1].pk,
},
{
'name': 'Test Provider 5',
'slug': 'test-provider-5',
'cid': 'Circuit 5',
'provider': providers[1].pk,
'type': circuit_types[1].pk,
},
{
'name': 'Test Provider 6',
'slug': 'test-provider-6',
'cid': 'Circuit 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)
self.assertEqual(Provider.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
model = CircuitTermination
brief_fields = ['circuit', 'id', 'term_side', 'url']
def test_update_provider(self):
@classmethod
def setUpTestData(cls):
SIDE_A = CircuitTerminationSideChoices.SIDE_A
SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
data = {
'name': 'Test Provider X',
'slug': 'test-provider-x',
}
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']
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
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 = {
'name': 'Test Circuit Type 4',
'slug': 'test-circuit-type-4',
}
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']
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
Circuit(cid='Circuit 2', provider=provider, type=circuit_type),
Circuit(cid='Circuit 3', provider=provider, type=circuit_type),
)
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 = {
'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 = [
cls.create_data = [
{
'cid': 'TEST0004',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
'circuit': circuits[2].pk,
'term_side': SIDE_A,
'site': sites[1].pk,
'port_speed': 200000,
},
{
'cid': 'TEST0005',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
},
{
'cid': 'TEST0006',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
'circuit': circuits[2].pk,
'term_side': SIDE_Z,
'site': sites[1].pk,
'port_speed': 200000,
},
]
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),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Provider X',
'slug': 'provider-x',
@ -26,7 +28,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'noc_contact': 'noc@example.com',
'admin_contact': 'admin@example.com',
'comments': 'Another provider',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@ -96,6 +98,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cid': 'Circuit X',
'provider': providers[1].pk,
@ -106,7 +110,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 1000,
'description': 'A new circuit',
'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@ -124,5 +128,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 2000,
'description': 'New description',
'comments': 'New comments',
}

View File

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

View File

@ -1,32 +1,35 @@
from rest_framework import serializers
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
)
from dcim import models
from utilities.api import ChoiceField, WritableNestedSerializer
__all__ = [
'NestedCableSerializer',
'NestedConsolePortSerializer',
'NestedConsolePortTemplateSerializer',
'NestedConsoleServerPortSerializer',
'NestedConsoleServerPortTemplateSerializer',
'NestedDeviceBaySerializer',
'NestedDeviceBayTemplateSerializer',
'NestedDeviceRoleSerializer',
'NestedDeviceSerializer',
'NestedDeviceTypeSerializer',
'NestedFrontPortSerializer',
'NestedFrontPortTemplateSerializer',
'NestedInterfaceSerializer',
'NestedInterfaceTemplateSerializer',
'NestedInventoryItemSerializer',
'NestedManufacturerSerializer',
'NestedPlatformSerializer',
'NestedPowerFeedSerializer',
'NestedPowerOutletSerializer',
'NestedPowerOutletTemplateSerializer',
'NestedPowerPanelSerializer',
'NestedPowerPortSerializer',
'NestedPowerPortTemplateSerializer',
'NestedRackGroupSerializer',
'NestedRackReservationSerializer',
'NestedRackRoleSerializer',
'NestedRackSerializer',
'NestedRearPortSerializer',
@ -46,7 +49,7 @@ class NestedRegionSerializer(WritableNestedSerializer):
site_count = serializers.IntegerField(read_only=True)
class Meta:
model = Region
model = models.Region
fields = ['id', 'url', 'name', 'slug', 'site_count']
@ -54,7 +57,7 @@ class NestedSiteSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
class Meta:
model = Site
model = models.Site
fields = ['id', 'url', 'name', 'slug']
@ -67,7 +70,7 @@ class NestedRackGroupSerializer(WritableNestedSerializer):
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackGroup
model = models.RackGroup
fields = ['id', 'url', 'name', 'slug', 'rack_count']
@ -76,7 +79,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackRole
model = models.RackRole
fields = ['id', 'url', 'name', 'slug', 'rack_count']
@ -85,10 +88,22 @@ class NestedRackSerializer(WritableNestedSerializer):
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = Rack
model = models.Rack
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
#
@ -98,7 +113,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
devicetype_count = serializers.IntegerField(read_only=True)
class Meta:
model = Manufacturer
model = models.Manufacturer
fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
@ -108,15 +123,47 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceType
model = models.DeviceType
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):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
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']
@ -124,7 +171,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
class Meta:
model = RearPortTemplate
model = models.RearPortTemplate
fields = ['id', 'url', 'name']
@ -132,7 +179,15 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
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']
@ -146,7 +201,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceRole
model = models.DeviceRole
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
@ -156,7 +211,7 @@ class NestedPlatformSerializer(WritableNestedSerializer):
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = Platform
model = models.Platform
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')
class Meta:
model = Device
model = models.Device
fields = ['id', 'url', 'name', 'display_name']
@ -174,7 +229,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta:
model = ConsoleServerPort
model = models.ConsoleServerPort
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)
class Meta:
model = ConsolePort
model = models.ConsolePort
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)
class Meta:
model = PowerOutlet
model = models.PowerOutlet
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)
class Meta:
model = PowerPort
model = models.PowerPort
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)
class Meta:
model = Interface
model = models.Interface
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@ -223,7 +278,7 @@ class NestedRearPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
class Meta:
model = RearPort
model = models.RearPort
fields = ['id', 'url', 'device', 'name', 'cable']
@ -232,7 +287,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
class Meta:
model = FrontPort
model = models.FrontPort
fields = ['id', 'url', 'device', 'name', 'cable']
@ -241,7 +296,16 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
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']
@ -253,7 +317,7 @@ class NestedCableSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta:
model = Cable
model = models.Cable
fields = ['id', 'url', 'label']
@ -267,7 +331,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
model = models.VirtualChassis
fields = ['id', 'url', 'master', 'member_count']
@ -280,7 +344,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer):
powerfeed_count = serializers.IntegerField(read_only=True)
class Meta:
model = PowerPanel
model = models.PowerPanel
fields = ['id', 'url', 'name', 'powerfeed_count']
@ -288,5 +352,5 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
class Meta:
model = PowerFeed
model = models.PowerFeed
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 rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.choices import *
from dcim.constants import *
@ -14,6 +13,7 @@ from dcim.models import (
VirtualChassis,
)
from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
from tenancy.api.nested_serializers import NestedTenantSerializer
@ -67,12 +67,11 @@ class RegionSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count']
class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=SiteStatusChoices, required=False)
region = NestedRegionSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False)
tags = TagListSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
@ -112,7 +111,7 @@ class RackRoleSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count']
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True)
@ -121,7 +120,6 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True)
@ -161,14 +159,14 @@ class RackUnitSerializer(serializers.Serializer):
device = NestedDeviceSerializer(read_only=True)
class RackReservationSerializer(ValidatedModelSerializer):
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
rack = NestedRackSerializer()
user = NestedUserSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags']
class RackElevationDetailFilterSerializer(serializers.Serializer):
@ -223,10 +221,9 @@ class ManufacturerSerializer(ValidatedModelSerializer):
]
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
class Meta:
@ -248,7 +245,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
fields = ['id', 'device_type', 'name', 'type']
fields = ['id', 'device_type', 'name', 'label', 'type']
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@ -261,7 +258,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'device_type', 'name', 'type']
fields = ['id', 'device_type', 'name', 'label', 'type']
class PowerPortTemplateSerializer(ValidatedModelSerializer):
@ -274,7 +271,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
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):
@ -295,7 +292,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
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):
@ -304,7 +301,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = InterfaceTemplate
fields = ['id', 'device_type', 'name', 'type', 'mgmt_only']
fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only']
class RearPortTemplateSerializer(ValidatedModelSerializer):
@ -331,7 +328,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta:
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_role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
@ -377,7 +374,6 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Device
@ -433,7 +429,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField()
class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@ -441,17 +437,16 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags',
]
class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@ -459,17 +454,16 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsolePort
fields = [
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags',
]
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
@ -487,19 +481,16 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
cable = NestedCableSerializer(
read_only=True
)
tags = TagListSerializerField(
required=False
)
class Meta:
model = PowerOutlet
fields = [
'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
'id', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
@ -507,17 +498,16 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = PowerPort
fields = [
'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
'id', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
@ -530,13 +520,12 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
many=True
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
count_ipaddresses = serializers.IntegerField(read_only=True)
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'id', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
'tagged_vlans', 'tags', 'count_ipaddresses',
]
@ -562,11 +551,10 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
return super().validate(data)
class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = RearPort
@ -584,38 +572,35 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name']
class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = FrontPort
fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags']
class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
fields = ['id', 'device', 'name', 'label', 'description', 'installed_device', 'tags']
#
# Inventory items
#
class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False)
class Meta:
model = InventoryItem
@ -629,7 +614,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
# Cables
#
class CableSerializer(ValidatedModelSerializer):
class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
@ -645,7 +630,7 @@ class CableSerializer(ValidatedModelSerializer):
model = Cable
fields = [
'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id',
'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit',
'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
]
def _get_termination(self, obj, side):
@ -708,9 +693,8 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
# Virtual chassis
#
class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
master = NestedDeviceSerializer()
tags = TagListSerializerField(required=False)
member_count = serializers.IntegerField(read_only=True)
class Meta:
@ -722,7 +706,7 @@ class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
# Power panels
#
class PowerPanelSerializer(ValidatedModelSerializer):
class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
site = NestedSiteSerializer()
rack_group = NestedRackGroupSerializer(
required=False,
@ -733,10 +717,10 @@ class PowerPanelSerializer(ValidatedModelSerializer):
class Meta:
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()
rack = NestedRackSerializer(
required=False,
@ -759,9 +743,6 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
tags = TagListSerializerField(
required=False
)
class Meta:
model = PowerFeed

View File

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

View File

@ -276,6 +276,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_L620P = 'nema-l6-20p'
TYPE_NEMA_L630P = 'nema-l6-30p'
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
TYPE_CS6361C = 'cs6361c'
TYPE_CS6365C = 'cs6365c'
@ -337,6 +341,10 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L620P, 'NEMA L6-20P'),
(TYPE_NEMA_L630P, 'NEMA L6-30P'),
(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', (
(TYPE_CS6361C, 'CS6361C'),
@ -405,6 +413,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_L620R = 'nema-l6-20r'
TYPE_NEMA_L630R = 'nema-l6-30r'
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
TYPE_CS6360C = 'CS6360C'
TYPE_CS6364C = 'CS6364C'
@ -467,6 +479,10 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L620R, 'NEMA L6-20R'),
(TYPE_NEMA_L630R, 'NEMA L6-30R'),
(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', (
(TYPE_CS6360C, 'CS6360C'),

View File

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

View File

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

View File

@ -22,7 +22,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
options={'ordering': ['name']},
),
migrations.AddField(
model_name='platform',

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
options={'ordering': ('name', 'pk')},
),
migrations.AlterModelOptions(
name='rack',

View File

@ -79,42 +79,42 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='consoleport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebay',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='inventoryitem',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlet',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleports,

View File

@ -75,37 +75,37 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='consoleporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebaytemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleporttemplates,

View File

@ -30,7 +30,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
options={'ordering': ('_name', 'pk')},
),
migrations.AlterModelOptions(
name='rack',
@ -43,17 +43,17 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='device',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
),
migrations.AddField(
model_name='rack',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='site',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_sites,

View File

@ -35,12 +35,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='interface',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.AddField(
model_name='interfacetemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.RunPython(
code=naturalize_interfacetemplates,

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 extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.models import ChangeLoggedModel
from utilities.mptt import TreeManager
from utilities.utils import serialize_object, to_meters
from utilities.validators import ExclusionValidator
from .device_component_templates import (
@ -32,11 +35,12 @@ from .device_component_templates import (
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from .device_components import (
CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
PowerPort, RearPort,
BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem,
PowerOutlet, PowerPort, RearPort,
)
__all__ = (
'BaseInterface',
'Cable',
'CableTermination',
'ConsolePort',
@ -102,6 +106,8 @@ class Region(MPTTModel, ChangeLoggedModel):
blank=True
)
objects = TreeManager()
csv_headers = ['name', 'slug', 'parent', 'description']
class MPTTMeta:
@ -243,6 +249,8 @@ class Site(ChangeLoggedModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
@ -325,6 +333,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
blank=True
)
objects = TreeManager()
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
class Meta:
@ -379,12 +389,16 @@ class RackRole(ChangeLoggedModel):
slug = models.SlugField(
unique=True
)
color = ColorField()
color = ColorField(
default=ColorChoices.COLOR_GREY
)
description = models.CharField(
max_length=200,
blank=True,
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description']
class Meta:
@ -523,6 +537,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
@ -680,7 +696,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
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).
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 exclude: List of devices IDs to exclude (useful when moving a device within a rack)
"""
# Gather all devices which consume U space within the rack
devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
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
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.
"""
reserved_units = {}
for r in self.reservations.all():
for r in self.reservations.unrestricted():
for u in r.units:
reserved_units[u] = r
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.
"""
power_stats = PowerFeed.objects.filter(
power_stats = PowerFeed.objects.unrestricted().filter(
rack=self
).annotate(
allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'),
@ -817,6 +834,9 @@ class RackReservation(ChangeLoggedModel):
description = models.CharField(
max_length=200
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
@ -897,6 +917,8 @@ class Manufacturer(ChangeLoggedModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description']
class Meta:
@ -979,9 +1001,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
]
@ -1190,7 +1213,9 @@ class DeviceRole(ChangeLoggedModel):
slug = models.SlugField(
unique=True
)
color = ColorField()
color = ColorField(
default=ColorChoices.COLOR_GREY
)
vm_role = models.BooleanField(
default=True,
verbose_name='VM Role',
@ -1201,6 +1226,8 @@ class DeviceRole(ChangeLoggedModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
class Meta:
@ -1258,6 +1285,8 @@ class Platform(ChangeLoggedModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
class Meta:
@ -1424,6 +1453,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
@ -1449,10 +1480,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
('rack', 'position', 'face'),
('virtual_chassis', 'vc_position'),
)
permissions = (
('napalm_read', 'Read-only access to devices via NAPALM'),
('napalm_write', 'Read/write access to devices via NAPALM'),
)
def __str__(self):
return self.display_name or super().__str__()
@ -1736,9 +1763,10 @@ class VirtualChassis(ChangeLoggedModel):
max_length=30,
blank=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['master', 'domain']
class Meta:
@ -1807,6 +1835,9 @@ class PowerPanel(ChangeLoggedModel):
name = models.CharField(
max_length=50
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'name']
@ -1911,9 +1942,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments',
@ -2078,6 +2110,9 @@ class Cable(ChangeLoggedModel):
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
@ -2110,9 +2145,9 @@ class Cable(ChangeLoggedModel):
"""
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_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
return instance
@ -2149,14 +2184,14 @@ class Cable(ChangeLoggedModel):
if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
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
):
raise ValidationError({
'termination_a': err_msg
})
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
):
raise ValidationError({
@ -2182,23 +2217,29 @@ class Cable(ChangeLoggedModel):
# Check that termination types are compatible
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
))
raise ValidationError(
f"Incompatible termination types: {self.termination_a_type} and {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(
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
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
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
if (

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@ -1,7 +1,7 @@
from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceCreateView
from ipam.views import ServiceEditView
from . import views
from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
@ -14,7 +14,7 @@ urlpatterns = [
# Regions
path('regions/', views.RegionListView.as_view(), name='region_list'),
path('regions/add/', views.RegionCreateView.as_view(), name='region_add'),
path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
@ -22,7 +22,7 @@ urlpatterns = [
# Sites
path('sites/', views.SiteListView.as_view(), name='site_list'),
path('sites/add/', views.SiteCreateView.as_view(), name='site_add'),
path('sites/add/', views.SiteEditView.as_view(), name='site_add'),
path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
@ -34,7 +34,7 @@ urlpatterns = [
# Rack groups
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'),
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
@ -42,7 +42,7 @@ urlpatterns = [
# Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
@ -50,7 +50,7 @@ urlpatterns = [
# Rack reservations
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'),
path('rack-reservations/add/', views.RackReservationEditView.as_view(), name='rackreservation_add'),
path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
@ -62,7 +62,7 @@ urlpatterns = [
# Racks
path('racks/', views.RackListView.as_view(), name='rack_list'),
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
path('racks/add/', views.RackCreateView.as_view(), name='rack_add'),
path('racks/add/', views.RackEditView.as_view(), name='rack_add'),
path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
@ -74,7 +74,7 @@ urlpatterns = [
# Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
@ -82,7 +82,7 @@ urlpatterns = [
# Device types
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
path('device-types/add/', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
@ -149,7 +149,7 @@ urlpatterns = [
# Device roles
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
@ -157,7 +157,7 @@ urlpatterns = [
# Platforms
path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
@ -165,7 +165,7 @@ urlpatterns = [
# Devices
path('devices/', views.DeviceListView.as_view(), name='device_list'),
path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
@ -179,7 +179,7 @@ urlpatterns = [
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>/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}),
# Console ports
@ -332,7 +332,7 @@ urlpatterns = [
# Power panels
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
path('power-panels/add/', views.PowerPanelEditView.as_view(), name='powerpanel_add'),
path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
@ -343,7 +343,7 @@ urlpatterns = [
# Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),

File diff suppressed because it is too large Load Diff

View File

@ -46,24 +46,19 @@ class WebhookAdmin(admin.ModelAdmin):
form = WebhookForm
fieldsets = (
(None, {
'fields': (
'name', 'obj_type', 'enabled',
)
'fields': ('name', 'obj_type', 'enabled')
}),
('Events', {
'fields': (
'type_create', 'type_update', 'type_delete',
)
'fields': ('type_create', 'type_update', 'type_delete')
}),
('HTTP Request', {
'fields': (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)
),
'classes': ('monospace',)
}),
('SSL', {
'fields': (
'ssl_verification', 'ca_file_path',
)
'fields': ('ssl_verification', 'ca_file_path')
})
)
@ -121,6 +116,8 @@ class CustomLinkForm(forms.ModelForm):
'url': forms.Textarea,
}
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 '
'which render as empty text will not be displayed.',
'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)
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 = [
'name', 'content_type', 'group_name', 'weight',
]
@ -149,8 +155,29 @@ class CustomLinkAdmin(admin.ModelAdmin):
# Graphs
#
class GraphForm(forms.ModelForm):
class Meta:
model = Graph
exclude = ()
widgets = {
'source': forms.Textarea,
'link': forms.Textarea,
}
@admin.register(Graph)
class GraphAdmin(admin.ModelAdmin):
fieldsets = (
('Graph', {
'fields': ('type', 'name', 'weight')
}),
('Templates', {
'fields': ('template_language', 'source', 'link'),
'classes': ('monospace',)
})
)
form = GraphForm
list_display = [
'name', 'type', 'weight', 'template_language', 'source',
]
@ -179,6 +206,15 @@ class ExportTemplateForm(forms.ModelForm):
@admin.register(ExportTemplate)
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 = [
'name', 'content_type', 'description', 'mime_type', 'file_extension',
]

View File

@ -1,15 +1,48 @@
from rest_framework import serializers
from extras.models import ReportResult
from extras import models
from utilities.api import WritableNestedSerializer
__all__ = [
'NestedConfigContextSerializer',
'NestedExportTemplateSerializer',
'NestedGraphSerializer',
'NestedReportResultSerializer',
'NestedTagSerializer',
]
#
# Reports
#
class NestedConfigContextSerializer(WritableNestedSerializer):
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):
url = serializers.HyperlinkedIdentityField(
@ -19,5 +52,5 @@ class NestedReportResultSerializer(serializers.ModelSerializer):
)
class Meta:
model = ReportResult
model = models.ReportResult
fields = ['url', 'created', 'user', 'failed']

View File

@ -95,6 +95,28 @@ class TagSerializer(ValidatedModelSerializer):
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
#

View File

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

View File

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

View File

@ -1,8 +1,8 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from dcim.models import DeviceRole, Platform, Region, Site
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):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add add/remove tags fields
self.fields['add_tags'] = TagField(required=False)
self.fields['remove_tags'] = TagField(required=False)
self.fields['add_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
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)"
)
def __init__(self, vars, *args, commit_default=True, **kwargs):
def __init__(self, *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
commit = self.fields.pop('_commit')
self.fields['_commit'] = commit

View File

@ -2,7 +2,7 @@
# Generated by Django 1.11 on 2017-04-04 19:58
from django.db import migrations, models
import django.db.models.deletion
import extras.models
import extras.utils
class Migration(migrations.Migration):
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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_width', models.PositiveSmallIntegerField()),
('name', models.CharField(blank=True, max_length=50)),

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34
from django.db import migrations, models
import extras.models
import extras.utils
class Migration(migrations.Migration):
@ -74,7 +74,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='imageattachment',
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(
model_name='topologymap',

View File

@ -16,7 +16,6 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
],
options={
'permissions': (('run_script', 'Can run script'),),
'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
from collections import OrderedDict
from datetime import date
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
@ -12,37 +10,14 @@ from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from django.utils.text import slugify
from rest_framework.utils.encoders import JSONEncoder
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge, render_jinja2
from .choices import *
from .constants import *
from .querysets import ConfigContextQuerySet
from .utils import FeatureQuery
__all__ = (
'ConfigContext',
'ConfigContextModel',
'CustomField',
'CustomFieldChoice',
'CustomFieldModel',
'CustomFieldValue',
'CustomLink',
'ExportTemplate',
'Graph',
'ImageAttachment',
'ObjectChange',
'ReportResult',
'Script',
'Tag',
'TaggedItem',
'Webhook',
)
from extras.choices import *
from extras.constants import *
from extras.querysets import ConfigContextQuerySet
from extras.utils import FeatureQuery, image_upload
#
@ -174,291 +149,6 @@ class Webhook(models.Model):
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
#
@ -542,6 +232,8 @@ class Graph(models.Model):
verbose_name='Link URL'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
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'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['content_type', 'name']
unique_together = [
@ -663,20 +357,6 @@ class ExportTemplate(models.Model):
# 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):
"""
An uploaded image which is associated with an object.
@ -888,9 +568,6 @@ class Script(models.Model):
"""
class Meta:
managed = False
permissions = (
('run_script', 'Can run script'),
)
#
@ -995,6 +672,8 @@ class ObjectChange(models.Model):
editable=False
)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
@ -1038,44 +717,3 @@ class ObjectChange(models.Model):
self.object_repr,
self.object_data,
)
#
# Tags
#
# TODO: figure out a way around this circular import for ObjectChange
from utilities.models import ChangeLoggedModel # noqa: E402
class Tag(TagBase, ChangeLoggedModel):
color = ColorField(
default='9e9e9e'
)
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 utilities.querysets import RestrictedQuerySet
class CustomFieldQueryset:
"""
@ -19,7 +21,7 @@ class CustomFieldQueryset:
yield obj
class ConfigContextQuerySet(QuerySet):
class ConfigContextQuerySet(RestrictedQuerySet):
def get_for_object(self, obj):
"""

View File

@ -100,7 +100,7 @@ class Report(object):
self.active_test = None
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
test_methods = []
@ -128,7 +128,7 @@ class Report(object):
@property
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):
"""

View File

@ -277,13 +277,6 @@ class BaseScript:
@classmethod
def _get_vars(cls):
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():
if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr
@ -297,8 +290,16 @@ class BaseScript:
"""
Return a Django form suitable for populating the context data required to run this Script.
"""
vars = self._get_vars()
form = ScriptForm(vars, data, files, initial=initial, commit_default=getattr(self.Meta, 'commit_default', True))
# Create a dynamic ScriptForm subclass from script variables
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

View File

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

View File

@ -5,13 +5,11 @@ from django.urls import reverse
from django.utils import timezone
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.models import ConfigContext, Graph, ExportTemplate, Tag
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase
from utilities.testing import APITestCase, APIViewTestCases
class AppTest(APITestCase):
@ -24,489 +22,150 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class GraphTest(APITestCase):
def setUp(self):
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 = {
class GraphTest(APIViewTestCases.APIViewTestCase):
model = Graph
brief_fields = ['id', 'name', 'url']
create_data = [
{
'type': 'dcim.site',
'name': 'Test Graph 4',
'name': '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',
'name': 'Test Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
},
{
'type': 'dcim.site',
'name': 'Test Graph 5',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
},
{
'type': 'dcim.site',
'name': 'Test Graph 6',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
},
]
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(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_graph(self):
data = {
},
{
'type': 'dcim.site',
'name': 'Test Graph X',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=99',
}
'name': 'Graph 5',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
},
{
'type': 'dcim.site',
'name': 'Graph 6',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
},
]
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.put(url, data, format='json', **self.header)
@classmethod
def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Site)
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 %}'
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
def test_get_exporttemplate(self):
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.exporttemplate1.name)
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 = {
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate
brief_fields = ['id', 'name', 'url']
create_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',
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
{
'content_type': 'dcim.device',
'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
{
'content_type': 'dcim.device',
'name': 'Test Export Template 6',
'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(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_exporttemplate(self):
data = {
},
{
'content_type': 'dcim.device',
'name': 'Test Export Template X',
'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}
},
{
'content_type': 'dcim.device',
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
]
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.put(url, data, format='json', **self.header)
@classmethod
def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Device)
self.assertHttpStatus(response, status.HTTP_200_OK)
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):
def setUp(self):
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',
'slug': 'test-tag-4',
},
{
'name': 'Test Tag 5',
'slug': 'test-tag-5',
},
{
'name': 'Test Tag 6',
'slug': 'test-tag-6',
},
]
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(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_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}
export_templates = (
ExportTemplate(
content_type=ct,
name='Export Template 1',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
),
ExportTemplate(
content_type=ct,
name='Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
),
ExportTemplate(
content_type=ct,
name='Export Template 3',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
),
)
self.configcontext2 = ConfigContext.objects.create(
name='Test Config Context 2',
weight=200,
data={'bar': 456}
ExportTemplate.objects.bulk_create(export_templates)
class TagTest(APIViewTestCases.APIViewTestCase):
model = Tag
brief_fields = ['color', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Tag 4',
'slug': 'tag-4',
},
{
'name': 'Tag 5',
'slug': 'tag-5',
},
{
'name': 'Tag 6',
'slug': 'tag-6',
},
]
@classmethod
def setUpTestData(cls):
tags = (
Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'),
Tag(name='Tag 3', slug='tag-3'),
)
self.configcontext3 = ConfigContext.objects.create(
name='Test Config Context 3',
weight=300,
data={'baz': 789}
Tag.objects.bulk_create(tags)
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
model = ConfigContext
brief_fields = ['id', 'name', 'url']
create_data = [
{
'name': 'Config Context 4',
'data': {'more_foo': True},
},
{
'name': 'Config Context 5',
'data': {'more_bar': False},
},
{
'name': 'Config Context 6',
'data': {'more_baz': None},
},
]
@classmethod
def setUpTestData(cls):
config_contexts = (
ConfigContext(name='Config Context 1', weight=100, data={'foo': 123}),
ConfigContext(name='Config Context 2', weight=200, data={'bar': 456}),
ConfigContext(name='Config Context 3', weight=300, data={'baz': 789}),
)
def test_get_configcontext(self):
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.configcontext1.name)
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',
'data': {'more_foo': True},
},
{
'name': 'Test Config Context 5',
'data': {'more_bar': False},
},
{
'name': 'Test Config Context 6',
'data': {'more_baz': None},
},
]
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(), 6)
for i in range(0, 3):
self.assertEqual(response.data[i]['name'], data[i]['name'])
self.assertEqual(response.data[i]['data'], data[i]['data'])
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)
ConfigContext.objects.bulk_create(config_contexts)
def test_render_configcontext_for_object(self):
# Create a Device for which we'll render a config context
manufacturer = Manufacturer.objects.create(
name='Test Manufacturer',
slug='test-manufacturer'
)
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
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 rendering config context data for a device.
"""
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
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')
site = Site.objects.create(name='Site-1', slug='site-1')
device = Device.objects.create(name='Device 1', device_type=devicetype, device_role=devicerole, site=site)
# Test default config contexts (created at test setup)
rendered_context = device.get_config_context()
@ -516,7 +175,7 @@ class ConfigContextTest(APITestCase):
# Add another context specific to the site
configcontext4 = ConfigContext(
name='Test Config Context 4',
name='Config Context 4',
data={'site_data': 'ABC'}
)
configcontext4.save()
@ -526,7 +185,7 @@ class ConfigContextTest(APITestCase):
# Override one of the default contexts
configcontext5 = ConfigContext(
name='Test Config Context 5',
name='Config Context 5',
weight=2000,
data={'foo': 999}
)
@ -536,12 +195,9 @@ class ConfigContextTest(APITestCase):
self.assertEqual(rendered_context['foo'], 999)
# Add a context which does NOT match our device and ensure it does not apply
site2 = Site.objects.create(
name='Test Site 2',
slug='test-site-2'
)
site2 = Site.objects.create(name='Site 2', slug='site-2')
configcontext6 = ConfigContext(
name='Test Config Context 6',
name='Config Context 6',
weight=2000,
data={'bar': 999}
)
@ -639,6 +295,7 @@ class CreatedUpdatedFilterTest(APITestCase):
)
def test_get_rack_created(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
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)
def test_get_rack_created_gte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
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)
def test_get_rack_created_lte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
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)
def test_get_rack_last_updated(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
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)
def test_get_rack_last_updated_gte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
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)
def test_get_rack_last_updated_lte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
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 extras.choices import *
from extras.constants import *
from extras.models import CustomField, CustomFieldValue, ObjectChange
from utilities.testing import APITestCase
@ -12,7 +11,6 @@ from utilities.testing import APITestCase
class ChangeLogTest(APITestCase):
def setUp(self):
super().setUp()
# Create a custom field on the Site model
@ -26,21 +24,17 @@ class ChangeLogTest(APITestCase):
cf.obj_type.set([ct])
def test_create_object(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'my_field': 'ABC'
},
'tags': [
'bar', 'foo'
],
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -52,10 +46,8 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site.save()
@ -65,14 +57,11 @@ class ChangeLogTest(APITestCase):
'custom_fields': {
'my_field': 'DEF'
},
'tags': [
'abc', 'xyz'
],
}
self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.change_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
@ -84,27 +73,23 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
def test_delete_object(self):
site = Site(
name='Test Site 1',
slug='test-site-1'
)
site.save()
site.tags.add('foo', 'bar')
CustomFieldValue.objects.create(
field=CustomField.objects.get(name='my_field'),
obj=site,
value='ABC'
)
self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.delete_site')
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.assertEqual(Site.objects.count(), 0)
@ -113,4 +98,3 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
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 django.contrib.contenttypes.models import ContentType
from django.test import Client, TestCase
from django.urls import reverse
from rest_framework import status
@ -9,7 +8,7 @@ from dcim.forms import SiteCSVForm
from dcim.models import Site
from extras.choices import *
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
@ -99,6 +98,19 @@ class CustomFieldTest(TestCase):
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):
@classmethod
@ -170,8 +182,9 @@ class CustomFieldAPITest(APITestCase):
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})
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['custom_fields'], {
'text_field': None,
@ -189,10 +202,10 @@ class CustomFieldAPITest(APITestCase):
site2_cfvs = {
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})
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['custom_fields']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
@ -209,8 +222,9 @@ class CustomFieldAPITest(APITestCase):
'name': 'Site 3',
'slug': 'site-3',
}
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -251,8 +265,9 @@ class CustomFieldAPITest(APITestCase):
'choice_field': self.cf_select_choice2.pk,
},
}
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
@ -297,8 +312,9 @@ class CustomFieldAPITest(APITestCase):
'slug': 'site-5',
},
)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(data))
@ -355,8 +371,9 @@ class CustomFieldAPITest(APITestCase):
'custom_fields': custom_field_data,
},
)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(data))
@ -398,8 +415,9 @@ class CustomFieldAPITest(APITestCase):
'number_field': 1234,
},
}
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)
self.assertHttpStatus(response, status.HTTP_200_OK)
@ -457,17 +475,10 @@ class CustomFieldChoiceAPITest(APITestCase):
class CustomFieldImportTest(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'dcim.view_site',
'dcim.add_site',
]
)
self.client = Client()
self.client.force_login(user)
user_permissions = (
'dcim.view_site',
'dcim.add_site',
)
@classmethod
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).
"""
def setUp(self):
super().setUp()
def test_create_tagged_item(self):
tags = self.create_tags("Foo", "Bar", "Baz")
data = {
'name': 'Test Site',
'slug': 'test-site',
'tags': ['Foo', 'Bar', 'Baz']
'tags': [t.pk for t in tags]
}
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.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'])
tags = [tag.name for tag in site.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))
self.assertListEqual(
sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "Baz"])
)
def test_update_tagged_item(self):
site = Site.objects.create(
name='Test Site',
slug='test-site'
)
site.tags.add('Foo', 'Bar', 'Baz')
site.tags.add("Foo", "Bar", "Baz")
self.create_tags("New Tag")
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})
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.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'])
tags = [tag.name for tag in site.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))
self.assertListEqual(
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):
model = Tag
# Disable inapplicable tests
test_create_object = None
test_import_objects = None
@classmethod
def setUpTestData(cls):
@ -33,21 +29,29 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'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 = {
'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
# Disable inapplicable tests
test_import_objects = None
# TODO: Resolve model discrepancies when creating/editing ConfigContexts
test_create_object = None
test_edit_object = None
@classmethod
def setUpTestData(cls):

View File

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

View File

@ -9,6 +9,8 @@ urlpatterns = [
# Tags
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/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
@ -18,7 +20,7 @@ urlpatterns = [
# Config contexts
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/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),

View File

@ -22,6 +22,22 @@ def is_taggable(obj):
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
class FeatureQuery:
"""

View File

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

View File

@ -1,6 +1,6 @@
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
__all__ = [
@ -9,6 +9,7 @@ __all__ = [
'NestedPrefixSerializer',
'NestedRIRSerializer',
'NestedRoleSerializer',
'NestedServiceSerializer',
'NestedVLANGroupSerializer',
'NestedVLANSerializer',
'NestedVRFSerializer',
@ -24,7 +25,7 @@ class NestedVRFSerializer(WritableNestedSerializer):
prefix_count = serializers.IntegerField(read_only=True)
class Meta:
model = VRF
model = models.VRF
fields = ['id', 'url', 'name', 'rd', 'prefix_count']
@ -37,7 +38,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
aggregate_count = serializers.IntegerField(read_only=True)
class Meta:
model = RIR
model = models.RIR
fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
@ -45,7 +46,7 @@ class NestedAggregateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
class Meta:
model = Aggregate
model = models.Aggregate
fields = ['id', 'url', 'family', 'prefix']
@ -59,7 +60,7 @@ class NestedRoleSerializer(WritableNestedSerializer):
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = Role
model = models.Role
fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count']
@ -68,7 +69,7 @@ class NestedVLANGroupSerializer(WritableNestedSerializer):
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = VLANGroup
model = models.VLANGroup
fields = ['id', 'url', 'name', 'slug', 'vlan_count']
@ -76,7 +77,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
model = models.VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
@ -88,7 +89,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
class Meta:
model = Prefix
model = models.Prefix
fields = ['id', 'url', 'family', 'prefix']
@ -96,10 +97,21 @@ class NestedPrefixSerializer(WritableNestedSerializer):
# IP addresses
#
class NestedIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta:
model = IPAddress
model = models.IPAddress
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 django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
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 tenancy.api.nested_serializers import NestedTenantSerializer
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 .nested_serializers import *
@ -22,9 +26,8 @@ from .nested_serializers import *
# VRFs
#
class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
@ -48,10 +51,9 @@ class RIRSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
rir = NestedRIRSerializer()
tags = TagListSerializerField(required=False)
class Meta:
model = Aggregate
@ -98,13 +100,12 @@ class VLANGroupSerializer(ValidatedModelSerializer):
return data
class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
prefix_count = serializers.IntegerField(read_only=True)
class Meta:
@ -133,7 +134,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
# Prefixes
#
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
site = NestedSiteSerializer(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)
status = ChoiceField(choices=PrefixStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Prefix
@ -226,25 +226,37 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer):
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)
vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPAddressStatusChoices, 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_outside = NestedIPAddressSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = IPAddress
fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id',
'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields',
'created', 'last_updated',
]
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):
"""
@ -270,7 +282,7 @@ class AvailableIPSerializer(serializers.Serializer):
# Services
#
class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
@ -280,7 +292,6 @@ class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
required=False,
many=True
)
tags = TagListSerializerField(required=False)
class Meta:
model = Service

View File

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

View File

@ -1,3 +1,5 @@
from django.db.models import Q
from .choices import IPAddressRoleChoices
# BGP ASN bounds
@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6
# 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_MAX = 128 # IPv6

View File

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

View File

@ -1,11 +1,11 @@
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from taggit.forms import TagField
from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
)
from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
@ -14,7 +14,7 @@ from utilities.forms import (
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .constants import *
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):
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
@ -141,7 +142,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
rir = DynamicModelChoiceField(
queryset=RIR.objects.all()
)
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
@ -292,7 +294,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Role.objects.all(),
required=False
)
tags = TagField(required=False)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Prefix
@ -517,10 +522,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
#
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(),
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(
queryset=VRF.objects.all(),
required=False,
@ -584,15 +612,16 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
required=False,
label='Make this the primary IP for the device/VM'
)
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent',
'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
'nat_inside', 'tenant_group', 'tenant', 'tags',
]
widgets = {
'status': StaticSelect2(),
@ -604,27 +633,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
if instance and instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
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_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
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
if self.instance.pk and self.instance.interface is not None:
parent = self.instance.interface.parent
if self.instance.pk and self.instance.assigned_object:
parent = self.instance.assigned_object.parent
if (
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
@ -634,32 +662,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
def clean(self):
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.
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(
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
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)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if interface and self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4:
parent.primary_ip4 = ipaddress
interface.parent.primary_ip4 = ipaddress
else:
parent.primary_ip6 = ipaddress
parent.save()
elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.primary_ip4 = None
parent.save()
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.primary_ip6 = None
parent.save()
interface.primary_ip6 = ipaddress
interface.parent.save()
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
return ipaddress
@ -676,11 +711,15 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False,
label='VRF'
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant',
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
]
widgets = {
'status': StaticSelect2(),
@ -727,7 +766,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
help_text='Parent VM of assigned interface (if any)'
)
interface = CSVModelChoiceField(
queryset=Interface.objects.all(),
queryset=Interface.objects.none(), # Can also refer to VMInterface
required=False,
to_field_name='name',
help_text='Assigned interface'
@ -746,21 +785,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
if data:
# Limit interface queryset by assigned device or virtual machine
# Limit interface queryset by assigned device
if data.get('device'):
params = {
f"device__{self.fields['device'].to_field_name}": data.get('device')
}
self.fields['interface'].queryset = Interface.objects.filter(
**{f"device__{self.fields['device'].to_field_name}": data['device']}
)
# Limit interface queryset by assigned device
elif data.get('virtual_machine'):
params = {
f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine')
}
else:
params = {
'device': None,
'virtual_machine': None,
}
self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
self.fields['interface'].queryset = VMInterface.objects.filter(
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
)
def clean(self):
super().clean()
@ -775,17 +810,9 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
def save(self, *args, **kwargs):
# Set interface
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
self.instance.interface = Interface.objects.get(
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']
)
# Set interface assignment
if self.cleaned_data['interface']:
self.instance.assigned_object = self.cleaned_data['interface']
ipaddress = super().save(*args, **kwargs)
@ -997,7 +1024,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Role.objects.all(),
required=False
)
tags = TagField(required=False)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = VLAN
@ -1164,7 +1194,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
min_value=SERVICE_PORT_MIN,
max_value=SERVICE_PORT_MAX
)
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
@ -1187,13 +1218,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
# Limit IP address choices to those assigned to interfaces of the parent device/VM
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(
interface_id__in=vc_interface_ids
interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
)
elif self.instance.virtual_machine:
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:
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 utilities.querysets import RestrictedQuerySet
class IPAddressManager(models.Manager):
class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)):
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
IP address as a /32 or /128.
"""
qs = super().get_queryset()
return qs.order_by(Inet(Host('address')))
return super().get_queryset().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
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, Q
from django.db.models import F
from django.urls import reverse
from taggit.managers import TaggableManager
@ -12,8 +13,9 @@ from dcim.models import Device, Interface
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .constants import *
from .fields import IPNetworkField, IPAddressField
@ -74,9 +76,10 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
clone_fields = [
'tenant', 'enforce_unique', 'description',
@ -131,6 +134,8 @@ class RIR(ChangeLoggedModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'is_private', 'description']
class Meta:
@ -179,9 +184,10 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['prefix', 'rir', 'date_added', 'description']
clone_fields = [
'rir', 'date_added', 'description',
@ -274,6 +280,8 @@ class Role(ChangeLoggedModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'weight', 'description']
class Meta:
@ -360,9 +368,9 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = PrefixQuerySet.as_manager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description',
@ -599,13 +607,22 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
blank=True,
help_text='The functional role of this IP'
)
interface = models.ForeignKey(
to='dcim.Interface',
on_delete=models.CASCADE,
related_name='ip_addresses',
assigned_object_type = models.ForeignKey(
to=ContentType,
limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=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(
to='self',
on_delete=models.SET_NULL,
@ -631,12 +648,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
objects = IPAddressManager()
tags = TaggableManager(through=TaggedItem)
objects = IPAddressManager()
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',
]
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()
if device:
if self.interface is None:
if self.assigned_object is None:
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({
'interface': "IP address is primary for device {} but assigned to {} ({})".format(
device, self.interface.device, self.interface
)
'interface': f"IP address is primary for device {device} but assigned to "
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()
if vm:
if self.interface is None:
if self.assigned_object is None:
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({
'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format(
vm, self.interface.virtual_machine, self.interface
)
'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
})
def save(self, *args, **kwargs):
@ -736,29 +752,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
super().save(*args, **kwargs)
def to_objectchange(self, action):
# Annotate the assigned Interface (if any)
try:
parent_obj = self.interface
except ObjectDoesNotExist:
parent_obj = None
# Annotate the assigned object, if any
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=parent_obj,
related_object=self.assigned_object,
object_data=serialize_object(self)
)
def to_csv(self):
# Determine if this IP is primary for a Device
is_primary = False
if self.address.version == 4 and getattr(self, 'primary_ip4_for', False):
is_primary = True
elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False):
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 (
self.address,
@ -766,9 +780,8 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.get_role_display(),
self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.interface.name if self.interface else None,
obj_type,
self.assigned_object_id,
is_primary,
self.dns_name,
self.description,
@ -789,18 +802,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.address.prefixlen = value
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):
return self.STATUS_CLASS_MAP.get(self.status)
@ -828,6 +829,8 @@ class VLANGroup(ChangeLoggedModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'site', 'description']
class Meta:
@ -923,9 +926,10 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'description',
@ -1039,9 +1043,10 @@ class Service(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description']
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):
"""

View File

@ -92,14 +92,6 @@ IPADDRESS_ASSIGN_LINK = """
{% endif %}
"""
IPADDRESS_PARENT = """
{% if record.interface %}
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
{% else %}
&mdash;
{% endif %}
"""
VRF_LINK = """
{% if record.vrf %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
@ -168,7 +160,7 @@ VLAN_MEMBER_UNTAGGED = """
VLAN_MEMBER_ACTIONS = """
{% 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 %}
"""
@ -378,6 +370,8 @@ class PrefixTable(BaseTable):
verbose_name='Pool'
)
add_prefetch = False
class Meta(BaseTable.Meta):
model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description')
@ -429,18 +423,14 @@ class IPAddressTable(BaseTable):
tenant = tables.TemplateColumn(
template_code=TENANT_LINK
)
parent = tables.TemplateColumn(
template_code=IPADDRESS_PARENT,
orderable=False
)
interface = tables.Column(
orderable=False
assigned = tables.BooleanColumn(
accessor='assigned_object_id'
)
class Meta(BaseTable.Meta):
model = IPAddress
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
)
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@ -463,11 +453,11 @@ class IPAddressDetailTable(IPAddressTable):
class Meta(IPAddressTable.Meta):
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',
)
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(
template_code=STATUS_LABEL
)
parent = tables.TemplateColumn(
template_code=IPADDRESS_PARENT,
orderable=False
)
interface = tables.Column(
assigned_object = tables.Column(
orderable=False
)
class Meta(BaseTable.Meta):
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
@ -665,6 +651,9 @@ class ServiceTable(BaseTable):
viewname='ipam:service',
args=[Accessor('pk')]
)
parent = tables.LinkColumn(
order_by=('device', 'virtual_machine')
)
tags = TagColumn(
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.filters import *
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
@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase):
)
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')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase):
)
VirtualMachine.objects.bulk_create(virtual_machines)
interfaces = (
Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'),
Interface(device=devices[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'),
vminterfaces = (
VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'),
VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'),
VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
VMInterface.objects.bulk_create(vminterfaces)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
@ -411,16 +415,16 @@ class IPAddressTestCase(TestCase):
Tenant.objects.bulk_create(tenants)
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.2/24', tenant=tenants[0], vrf=vrfs[0], interface=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.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.1/25', tenant=None, vrf=None, interface=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::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], 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::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::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
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], 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], 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], 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, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
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], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
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], 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, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
)
IPAddress.objects.bulk_create(ipaddresses)
@ -487,7 +491,14 @@ class IPAddressTestCase(TestCase):
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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):
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 ipam.choices import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.models import Tenant
from utilities.testing import ViewTestCases
@ -14,19 +15,27 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@classmethod
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(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'),
VRF(name='VRF 3', rd='65000:3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'VRF X',
'rd': '65000:999',
'tenant': None,
'tenant': tenants[0].pk,
'enforce_unique': True,
'description': 'A new VRF',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@ -37,7 +46,7 @@ class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
cls.bulk_edit_data = {
'tenant': None,
'tenant': tenants[1].pk,
'enforce_unique': False,
'description': 'New description',
}
@ -88,12 +97,14 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Aggregate(prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'prefix': IPNetwork('10.99.0.0/16'),
'rir': rirs[1].pk,
'date_added': datetime.date(2020, 1, 1),
'description': 'A new aggregate',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
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]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'prefix': IPNetwork('192.0.2.0/24'),
'site': sites[1].pk,
@ -176,7 +189,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'role': roles[1].pk,
'is_pool': True,
'description': 'A new prefix',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@ -215,17 +228,18 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
IPAddress(address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'vrf': vrfs[1].pk,
'address': IPNetwork('192.0.2.99/24'),
'tenant': None,
'status': IPAddressStatusChoices.STATUS_RESERVED,
'role': IPAddressRoleChoices.ROLE_ANYCAST,
'interface': None,
'nat_inside': None,
'dns_name': 'example',
'description': 'A new IP address',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
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]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'site': sites[1].pk,
'group': vlangroups[1].pk,
@ -313,7 +329,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'status': VLANStatusChoices.STATUS_RESERVED,
'role': roles[1].pk,
'description': 'A new VLAN',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
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
# TODO: Resolve URL for Service creation
test_create_object = None
@classmethod
def setUpTestData(cls):
@ -354,6 +377,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'virtual_machine': None,
@ -362,7 +387,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'port': 999,
'ipaddresses': [],
'description': 'A new service',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (

View File

@ -9,7 +9,7 @@ urlpatterns = [
# VRFs
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/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
@ -20,7 +20,7 @@ urlpatterns = [
# RIRs
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/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
@ -28,7 +28,7 @@ urlpatterns = [
# Aggregates
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/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
@ -39,7 +39,7 @@ urlpatterns = [
# Roles
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/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
@ -47,7 +47,7 @@ urlpatterns = [
# Prefixes
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/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
@ -60,7 +60,7 @@ urlpatterns = [
# IP addresses
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/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
@ -73,7 +73,7 @@ urlpatterns = [
# VLAN groups
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/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
@ -82,7 +82,7 @@ urlpatterns = [
# VLANs
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/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),

View File

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

View File

@ -2,7 +2,7 @@ from django.conf import settings
from django.db.models import QuerySet
from rest_framework import authentication, exceptions
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.utils import formatting
@ -51,7 +51,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
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
for unsafe requests (POST/PUT/PATCH/DELETE).
@ -74,15 +74,29 @@ class TokenPermissions(DjangoModelPermissions):
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):
# If token authentication is in use, verify that the token allows write operations (for unsafe methods).
if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
if not request.auth.write_enabled:
return False
# Enforce Token write ability
if not self._verify_write_permission(request):
return False
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

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