mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-08 09:59:30 +01:00
Compare commits
121 Commits
v1.9.6
...
v2.0-beta1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5f9491811 | ||
|
|
04e09c0078 | ||
|
|
1791a5bb11 | ||
|
|
3e6a99fc22 | ||
|
|
a5419ecc5c | ||
|
|
a36b138efe | ||
|
|
6d30fdb83d | ||
|
|
5c4741c5d4 | ||
|
|
93c748bd3c | ||
|
|
7ba6e320e7 | ||
|
|
54468ab1a8 | ||
|
|
01f5435f63 | ||
|
|
22768ff6c6 | ||
|
|
122526a9d0 | ||
|
|
6cb36a6cee | ||
|
|
925afe0999 | ||
|
|
f743410b4e | ||
|
|
4a2206ecb1 | ||
|
|
ffde2c96c7 | ||
|
|
2bd46230be | ||
|
|
b04fe21d65 | ||
|
|
266f9cc370 | ||
|
|
42fd14f5c0 | ||
|
|
1988c02b7f | ||
|
|
517eaa8b80 | ||
|
|
1f78462f58 | ||
|
|
36bbcc8559 | ||
|
|
671d53877a | ||
|
|
97710a4576 | ||
|
|
c08fae8bce | ||
|
|
f02dd2f439 | ||
|
|
e544f1fa1e | ||
|
|
130ff27f26 | ||
|
|
79a9ac3bc8 | ||
|
|
c5308d51f4 | ||
|
|
a6f4de5817 | ||
|
|
8825a03033 | ||
|
|
abdfc5c597 | ||
|
|
be2faaa110 | ||
|
|
f33269e50b | ||
|
|
bbc355df07 | ||
|
|
d58f9031d1 | ||
|
|
0312016f89 | ||
|
|
e3ae013e42 | ||
|
|
07a2b136b8 | ||
|
|
3d76a982aa | ||
|
|
3dc15068b9 | ||
|
|
4cb30f1ce4 | ||
|
|
b868de8d67 | ||
|
|
04aedcc056 | ||
|
|
105d17748e | ||
|
|
dd27950fae | ||
|
|
9e4e3a8dfa | ||
|
|
4d4441217f | ||
|
|
7e51ca9912 | ||
|
|
94a29be415 | ||
|
|
9dfda83946 | ||
|
|
41826fc3cb | ||
|
|
0ed13f6943 | ||
|
|
6c2ed1be22 | ||
|
|
ddec424429 | ||
|
|
7e6d061646 | ||
|
|
c19725506d | ||
|
|
a6ceaf8d96 | ||
|
|
f43fbffdf7 | ||
|
|
68c099a2af | ||
|
|
4f6d2a8b71 | ||
|
|
d58a8ebba0 | ||
|
|
6be465fe9b | ||
|
|
26225aff57 | ||
|
|
fd55360672 | ||
|
|
0b10d98e0b | ||
|
|
be0a3fb1f2 | ||
|
|
02e89d77bb | ||
|
|
a7a7b956b1 | ||
|
|
9b39ba169c | ||
|
|
90fe556e5f | ||
|
|
c0152940f9 | ||
|
|
8f42f59a80 | ||
|
|
f1518226bd | ||
|
|
21281789e0 | ||
|
|
b71566f206 | ||
|
|
0e04d20762 | ||
|
|
7040086201 | ||
|
|
6f3c3b6d61 | ||
|
|
37f250ddc1 | ||
|
|
35f310885e | ||
|
|
616ca4fe1f | ||
|
|
a9fe39459a | ||
|
|
a42eeb12d2 | ||
|
|
cf66f67fb6 | ||
|
|
2408d78f47 | ||
|
|
4f8a5eb1a0 | ||
|
|
06e5966cb4 | ||
|
|
ea51f1c896 | ||
|
|
77e5450746 | ||
|
|
6e10fea119 | ||
|
|
f52c247bd5 | ||
|
|
0dd857f7a2 | ||
|
|
bb1f97abc2 | ||
|
|
e1cd846c9a | ||
|
|
1fcc2b0029 | ||
|
|
173a6eee03 | ||
|
|
d9e4017677 | ||
|
|
7beac0b105 | ||
|
|
f0fef94a4f | ||
|
|
78cd4481e4 | ||
|
|
0cf029edd4 | ||
|
|
c0dac1383d | ||
|
|
a3d0d4a5bf | ||
|
|
12d263999b | ||
|
|
fa900d5dbb | ||
|
|
ddc2c8d110 | ||
|
|
acfba410dd | ||
|
|
b8ca530c55 | ||
|
|
b31c097531 | ||
|
|
0f9fe8648e | ||
|
|
791a641eef | ||
|
|
c5fba24cc5 | ||
|
|
0b228ed6d3 | ||
|
|
062a5bfe8d |
@@ -1,3 +1,7 @@
|
||||
**The [2017 NetBox User Survey](https://goo.gl/forms/75HnNS2iE0Y1hVFH3) is open!** Please consider taking a moment to respond. Your feedback helps shape the pace and focus of NetBox development. The survey will remain open until 2017-03-31. Results will be published on the mailing list.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers.
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# API Integration
|
||||
|
||||
NetBox features a read-only REST API which can be used to integrate it with
|
||||
other applications.
|
||||
|
||||
In the future, both read and write actions will be available via the API.
|
||||
|
||||
## Clients
|
||||
|
||||
The easiest way to start integrating your applications with NetBox is to make
|
||||
use of an API client. If you build or discover an API client that is not part
|
||||
of this list, please send a pull request!
|
||||
|
||||
- **Go**: [github.com/digitalocean/go-netbox](https://github.com/digitalocean/go-netbox)
|
||||
|
||||
## Documentation
|
||||
|
||||
If you wish to build a new API client or simply explore the NetBox API,
|
||||
Swagger documentation can be found at the URL `/api/docs/` on a NetBox server.
|
||||
48
docs/api/authentication.md
Normal file
48
docs/api/authentication.md
Normal file
@@ -0,0 +1,48 @@
|
||||
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/`.
|
||||
|
||||
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
|
||||
|
||||
By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
|
||||
|
||||
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
|
||||
|
||||
# Authenticating to the API
|
||||
|
||||
By default, read operations will be available without authentication. In this case, a token may be included in the request, but is not necessary.
|
||||
|
||||
```
|
||||
$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
|
||||
{
|
||||
"count": 10,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
|
||||
However, if the `[LOGIN_REQUIRED](../configuration/optional-settings/#login_required)` configuration setting has been set to `True`, all requests must be authenticated.
|
||||
|
||||
```
|
||||
$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
|
||||
{
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
```
|
||||
|
||||
To authenticate to the API, set the HTTP `Authorization` header to the string `Token ` (note the trailing space) followed by the token key.
|
||||
|
||||
```
|
||||
$ curl -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
|
||||
{
|
||||
"count": 10,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Additionally, the browsable interface to the API (which can be seen by navigating to the API root `/api/` in a web browser) will attempt to authenticate requests using the same cookie that the normal NetBox front end uses. Thus, if you have logged into NetBox, you will be logged into the browsable API as well.
|
||||
138
docs/api/examples.md
Normal file
138
docs/api/examples.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# API Examples
|
||||
|
||||
Supported HTTP methods:
|
||||
|
||||
* `GET`: Retrieve an object or list of objects
|
||||
* `POST`: Create a new object
|
||||
* `PUT`: Update an existing object
|
||||
* `DELETE`: Delete an existing object
|
||||
|
||||
To authenticate a request, attach your token in an `Authorization` header:
|
||||
|
||||
```
|
||||
curl -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0"
|
||||
```
|
||||
|
||||
### Retrieving a list of sites
|
||||
|
||||
Send a `GET` request to the object list endpoint. The response contains a paginated list of JSON objects.
|
||||
|
||||
```
|
||||
$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
|
||||
{
|
||||
"count": 14,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Corporate HQ",
|
||||
"slug": "corporate-hq",
|
||||
"region": null,
|
||||
"tenant": null,
|
||||
"facility": "",
|
||||
"asn": null,
|
||||
"physical_address": "742 Evergreen Terrace, Springfield, USA",
|
||||
"shipping_address": "",
|
||||
"contact_name": "",
|
||||
"contact_phone": "",
|
||||
"contact_email": "",
|
||||
"comments": "",
|
||||
"custom_fields": {},
|
||||
"count_prefixes": 108,
|
||||
"count_vlans": 46,
|
||||
"count_racks": 8,
|
||||
"count_devices": 254,
|
||||
"count_circuits": 6
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Retrieving a single site by ID
|
||||
|
||||
Send a `GET` request to the object detail endpoint. The response contains a single JSON object.
|
||||
|
||||
```
|
||||
$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/6/
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Corporate HQ",
|
||||
"slug": "corporate-hq",
|
||||
"region": null,
|
||||
"tenant": null,
|
||||
"facility": "",
|
||||
"asn": null,
|
||||
"physical_address": "742 Evergreen Terrace, Springfield, USA",
|
||||
"shipping_address": "",
|
||||
"contact_name": "",
|
||||
"contact_phone": "",
|
||||
"contact_email": "",
|
||||
"comments": "",
|
||||
"custom_fields": {},
|
||||
"count_prefixes": 108,
|
||||
"count_vlans": 46,
|
||||
"count_racks": 8,
|
||||
"count_devices": 254,
|
||||
"count_circuits": 6
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a new site
|
||||
|
||||
Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required.
|
||||
|
||||
```
|
||||
$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}'
|
||||
{
|
||||
"id": 16,
|
||||
"name": "My New Site",
|
||||
"slug": "my-new-site",
|
||||
"region": null,
|
||||
"tenant": null,
|
||||
"facility": "",
|
||||
"asn": null,
|
||||
"physical_address": "",
|
||||
"shipping_address": "",
|
||||
"contact_name": "",
|
||||
"contact_phone": "",
|
||||
"contact_email": "",
|
||||
"comments": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Modify an existing site
|
||||
|
||||
Make an authenticated `PUT` request to the site detail endpoint. As with a create (POST) request, all mandatory fields must be included.
|
||||
|
||||
```
|
||||
$ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}'
|
||||
```
|
||||
|
||||
### Delete an existing site
|
||||
|
||||
Send an authenticated `DELETE` request to the site detail endpoint.
|
||||
|
||||
```
|
||||
$ curl -v X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/
|
||||
* Connected to localhost (127.0.0.1) port 8000 (#0)
|
||||
> DELETE /api/dcim/sites/16/ HTTP/1.1
|
||||
> User-Agent: curl/7.35.0
|
||||
> Host: localhost:8000
|
||||
> Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
|
||||
> Content-Type: application/json
|
||||
> Accept: application/json; indent=4
|
||||
>
|
||||
* HTTP 1.0, assume close after body
|
||||
< HTTP/1.0 204 No Content
|
||||
< Date: Mon, 20 Mar 2017 16:13:08 GMT
|
||||
< Server: WSGIServer/0.1 Python/2.7.6
|
||||
< Vary: Accept, Cookie
|
||||
< X-Frame-Options: SAMEORIGIN
|
||||
< Allow: GET, PUT, PATCH, DELETE, OPTIONS
|
||||
<
|
||||
* Closing connection 0
|
||||
```
|
||||
|
||||
The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.
|
||||
138
docs/api/overview.md
Normal file
138
docs/api/overview.md
Normal file
@@ -0,0 +1,138 @@
|
||||
NetBox v2.0 and later includes a full-featured REST API that allows its data model to be read and manipulated externally.
|
||||
|
||||
# URL Hierarchy
|
||||
|
||||
NetBox's entire REST API is housed under the API root, `/api/`. The API's URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application:
|
||||
|
||||
* /api/circuits/providers/
|
||||
* /api/circuits/circuits/
|
||||
|
||||
Likewise, the site, rack, and device objects are located under the "DCIM" application:
|
||||
|
||||
* /api/dcim/sites/
|
||||
* /api/dcim/racks/
|
||||
* /api/dcim/devices/
|
||||
|
||||
The full hierarchy of available endpoints can be viewed by navigating to the API root (e.g. /api/) in a web browser.
|
||||
|
||||
Each model generally has two URLs associated with it: a list URL and a detail URL. The list URL is used to request a list of multiple objects or to create a new object. The detail URL is used to retrieve, update, or delete an existing object. All objects are referenced by their numeric primary key (ID).
|
||||
|
||||
* /api/dcim/devices/ - List devices or create a new device
|
||||
* /api/dcim/devices/123/ - Retrieve, update, or delete the device with ID 123
|
||||
|
||||
Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123:
|
||||
|
||||
```
|
||||
GET /api/dcim/interfaces/?device_id=123
|
||||
```
|
||||
|
||||
# Serialization
|
||||
|
||||
The NetBox API employs three types of serializers to represent model data:
|
||||
|
||||
* Base serializer
|
||||
* Nested serializer
|
||||
* Writable serializer
|
||||
|
||||
The base serializer is used to represent the default view of a model. This includes all database table fields which comprise the model, and may include additional metadata. A base serializer includes relationships to parent objects, but **does not** include child objects. For example, the `VLANSerializer` includes a nested representation its parent VLANGroup (if any), but does not include any assigned Prefixes.
|
||||
|
||||
```
|
||||
{
|
||||
"id": 1048,
|
||||
"site": {
|
||||
"id": 7,
|
||||
"url": "http://localhost:8000/api/dcim/sites/7/",
|
||||
"name": "Corporate HQ",
|
||||
"slug": "corporate-hq"
|
||||
},
|
||||
"group": {
|
||||
"id": 4,
|
||||
"url": "http://localhost:8000/api/ipam/vlan-groups/4/",
|
||||
"name": "Production",
|
||||
"slug": "production"
|
||||
},
|
||||
"vid": 101,
|
||||
"name": "Users-Floor1",
|
||||
"tenant": null,
|
||||
"status": [
|
||||
1,
|
||||
"Active"
|
||||
],
|
||||
"role": {
|
||||
"id": 9,
|
||||
"url": "http://localhost:8000/api/ipam/roles/9/",
|
||||
"name": "User Access",
|
||||
"slug": "user-access"
|
||||
},
|
||||
"description": "",
|
||||
"display_name": "101 (Users-Floor1)",
|
||||
"custom_fields": {}
|
||||
}
|
||||
```
|
||||
|
||||
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name.
|
||||
|
||||
When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
|
||||
|
||||
```
|
||||
{
|
||||
"id": 1201,
|
||||
"site": 7,
|
||||
"group": 4,
|
||||
"vid": 102,
|
||||
"name": "Users-Floor2",
|
||||
"tenant": null,
|
||||
"status": 1,
|
||||
"role": 9,
|
||||
"description": ""
|
||||
}
|
||||
```
|
||||
|
||||
# Pagination
|
||||
|
||||
API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes:
|
||||
|
||||
* `count`: The total count of all objects matching the query
|
||||
* `next`: A hyperlink to the next page of results (if applicable)
|
||||
* `previous`: A hyperlink to the previous page of results (if applicable)
|
||||
* `results`: The list of returned objects
|
||||
|
||||
Here is an example of a paginated response:
|
||||
|
||||
```
|
||||
HTTP 200 OK
|
||||
Allow: GET, POST, OPTIONS
|
||||
Content-Type: application/json
|
||||
Vary: Accept
|
||||
|
||||
{
|
||||
"count": 2861,
|
||||
"next": "http://localhost:8000/api/dcim/devices/?limit=50&offset=50",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 123,
|
||||
"name": "DeviceName123",
|
||||
...
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The default page size derives from the `[PAGINATE_COUNT](../configuration/optional-settings/#paginate_count)` configuration setting, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
|
||||
|
||||
```
|
||||
http://localhost:8000/api/dcim/devices/?limit=100
|
||||
```
|
||||
|
||||
The response will return devices 1 through 100. The URL provided in the `next` attribute of the response will return devices 101 through 200:
|
||||
|
||||
```
|
||||
{
|
||||
"count": 2861,
|
||||
"next": "http://localhost:8000/api/dcim/devices/?limit=100&offset=100",
|
||||
"previous": null,
|
||||
"results": [...]
|
||||
}
|
||||
```
|
||||
136
docs/api/working-with-secrets.md
Normal file
136
docs/api/working-with-secrets.md
Normal file
@@ -0,0 +1,136 @@
|
||||
As with most other objects, the NetBox API can be used to create, modify, and delete secrets. However, additional steps are needed to encrypt or decrypt secret data.
|
||||
|
||||
# Generating a Session Key
|
||||
|
||||
In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../data-model/secrets/#user-keys). The private key must be POSTed with the name `private_key`.
|
||||
|
||||
```
|
||||
$ curl -X POST http://localhost:8000/api/secrets/get-session-key/ \
|
||||
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
--data-urlencode "private_key@<filename>"
|
||||
{
|
||||
"session_key": "dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
|
||||
}
|
||||
```
|
||||
|
||||
!!! note
|
||||
To read the private key from a file, use the convention above. Alternatively, the private key can be read from an environment variable using `--data-urlencode "private_key=$PRIVATE_KEY"`.
|
||||
|
||||
The request uses your private key to unlock your stored copy of the master key and generate a session key which can be attached in the `X-Session-Key` header of future API requests.
|
||||
|
||||
# Retrieving Secrets
|
||||
|
||||
A session key is not needed to retrieve unencrypted secrets: The secret is returned like any normal object with its `plaintext` field set to null.
|
||||
|
||||
```
|
||||
$ curl http://localhost:8000/api/secrets/secrets/2587/ \
|
||||
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
|
||||
-H "Accept: application/json; indent=4"
|
||||
{
|
||||
"id": 2587,
|
||||
"device": {
|
||||
"id": 1827,
|
||||
"url": "http://localhost:8000/api/dcim/devices/1827/",
|
||||
"name": "MyTestDevice",
|
||||
"display_name": "MyTestDevice"
|
||||
},
|
||||
"role": {
|
||||
"id": 1,
|
||||
"url": "http://localhost:8000/api/secrets/secret-roles/1/",
|
||||
"name": "Login Credentials",
|
||||
"slug": "login-creds"
|
||||
},
|
||||
"name": "admin",
|
||||
"plaintext": null,
|
||||
"hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=",
|
||||
"created": "2017-03-21",
|
||||
"last_updated": "2017-03-21T19:28:44.265582Z"
|
||||
}
|
||||
```
|
||||
|
||||
To decrypt a secret, we must include our session key in the `X-Session-Key` header:
|
||||
|
||||
```
|
||||
$ curl http://localhost:8000/api/secrets/secrets/2587/ \
|
||||
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
|
||||
{
|
||||
"id": 2587,
|
||||
"device": {
|
||||
"id": 1827,
|
||||
"url": "http://localhost:8000/api/dcim/devices/1827/",
|
||||
"name": "MyTestDevice",
|
||||
"display_name": "MyTestDevice"
|
||||
},
|
||||
"role": {
|
||||
"id": 1,
|
||||
"url": "http://localhost:8000/api/secrets/secret-roles/1/",
|
||||
"name": "Login Credentials",
|
||||
"slug": "login-creds"
|
||||
},
|
||||
"name": "admin",
|
||||
"plaintext": "foobar",
|
||||
"hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=",
|
||||
"created": "2017-03-21",
|
||||
"last_updated": "2017-03-21T19:28:44.265582Z"
|
||||
}
|
||||
```
|
||||
|
||||
Lists of secrets can be decrypted in this manner as well:
|
||||
|
||||
```
|
||||
$ curl http://localhost:8000/api/secrets/secrets/?limit=3 \
|
||||
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
|
||||
{
|
||||
"count": 3482,
|
||||
"next": "http://localhost:8000/api/secrets/secrets/?limit=3&offset=3",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 2587,
|
||||
...
|
||||
"plaintext": "foobar",
|
||||
...
|
||||
},
|
||||
{
|
||||
"id": 2588,
|
||||
...
|
||||
"plaintext": "MyP@ssw0rd!",
|
||||
...
|
||||
},
|
||||
{
|
||||
"id": 2589,
|
||||
...
|
||||
"plaintext": "AnotherSecret!",
|
||||
...
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
# Creating Secrets
|
||||
|
||||
Session keys are also used to decrypt new or modified secrets. This is done by setting the `plaintext` field of the submitted object:
|
||||
|
||||
```
|
||||
$ curl -X POST http://localhost:8000/api/secrets/secrets/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" \
|
||||
--data '{"device": 1827, "role": 1, "name": "backup", "plaintext": "Drowssap1"}'
|
||||
{
|
||||
"id": 2590,
|
||||
"device": 1827,
|
||||
"role": 1,
|
||||
"name": "backup",
|
||||
"plaintext": "Drowssap1"
|
||||
}
|
||||
```
|
||||
|
||||
!!! note
|
||||
Don't forget to include the `Content-Type: application/json` header when making a POST request.
|
||||
@@ -38,6 +38,22 @@ BASE_PATH = 'netbox/'
|
||||
|
||||
---
|
||||
|
||||
## CORS_ORIGIN_ALLOW_ALL
|
||||
|
||||
Default: False
|
||||
|
||||
If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
|
||||
|
||||
---
|
||||
|
||||
## CORS_ORIGIN_WHITELIST
|
||||
|
||||
## CORS_ORIGIN_REGEX_WHITELIST
|
||||
|
||||
These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.)
|
||||
|
||||
---
|
||||
|
||||
## DEBUG
|
||||
|
||||
Default: False
|
||||
|
||||
@@ -10,6 +10,10 @@ Sites can be assigned an optional facility ID to identify the actual facility ho
|
||||
|
||||
Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned.
|
||||
|
||||
### Regions
|
||||
|
||||
Sites can optionally be arranged by geographic region. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy.
|
||||
|
||||
---
|
||||
|
||||
# Racks
|
||||
@@ -89,9 +93,12 @@ A device's platform is used to denote the type of software running on it. This c
|
||||
|
||||
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
|
||||
|
||||
### Modules
|
||||
### Inventory Items
|
||||
|
||||
A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each module can optionally be assigned to a manufacturer.
|
||||
Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each item can optionally be assigned a manufacturer.
|
||||
|
||||
!!! note
|
||||
Prior to version 2.0, inventory items were called modules.
|
||||
|
||||
### Components
|
||||
|
||||
@@ -109,6 +116,3 @@ Console ports connect only to console server ports, and power ports connect only
|
||||
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
|
||||
|
||||
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
|
||||
|
||||
!!! note
|
||||
Child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply, which do not provide their own management plane.
|
||||
|
||||
@@ -6,7 +6,6 @@ Python 3:
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
|
||||
# update-alternatives --install /usr/bin/python python /usr/bin/python3 1
|
||||
```
|
||||
|
||||
Python 2:
|
||||
@@ -21,9 +20,7 @@ Python 3:
|
||||
|
||||
```no-highlight
|
||||
# yum install -y epel-release
|
||||
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
|
||||
# easy_install-3.4 pip
|
||||
# ln -s -f python3.4 /usr/bin/python
|
||||
# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
|
||||
```
|
||||
|
||||
Python 2:
|
||||
@@ -86,14 +83,6 @@ Checking connectivity... done.
|
||||
|
||||
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
|
||||
|
||||
Python 3:
|
||||
|
||||
```no-highlight
|
||||
# pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
Python 2:
|
||||
|
||||
```no-highlight
|
||||
# pip install -r requirements.txt
|
||||
```
|
||||
@@ -183,7 +172,7 @@ Superuser created successfully.
|
||||
# Collect Static Files
|
||||
|
||||
```no-highlight
|
||||
# ./manage.py collectstatic --no-input
|
||||
# ./manage.py collectstatic
|
||||
|
||||
You have requested to collect static files at the destination
|
||||
location as specified in your settings:
|
||||
|
||||
@@ -5,14 +5,13 @@ NetBox requires a PostgreSQL database to store data. (Please note that MySQL is
|
||||
**Debian/Ubuntu**
|
||||
|
||||
```no-highlight
|
||||
# apt-get update
|
||||
# apt-get install -y postgresql libpq-dev
|
||||
# apt-get install -y postgresql libpq-dev python-psycopg2
|
||||
```
|
||||
|
||||
**CentOS/RHEL**
|
||||
|
||||
```no-highlight
|
||||
# yum install -y postgresql postgresql-server postgresql-devel
|
||||
# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
|
||||
# postgresql-setup initdb
|
||||
```
|
||||
|
||||
|
||||
@@ -19,7 +19,11 @@ pages:
|
||||
- 'Secrets': 'data-model/secrets.md'
|
||||
- 'Tenancy': 'data-model/tenancy.md'
|
||||
- 'Extras': 'data-model/extras.md'
|
||||
- 'API Integration': 'api-integration.md'
|
||||
- 'API':
|
||||
- 'Overview': 'api/overview.md'
|
||||
- 'Authentication': 'api/authentication.md'
|
||||
- 'Working with Secrets': 'api/working-with-secrets.md'
|
||||
- 'Examples': 'api/examples.md'
|
||||
|
||||
markdown_extensions:
|
||||
- admonition:
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
|
||||
|
||||
#
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
class ProviderSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
'custom_fields']
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
class ProviderNestedSerializer(ProviderSerializer):
|
||||
class NestedProviderSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
|
||||
|
||||
class Meta(ProviderSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class WritableProviderSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
|
||||
|
||||
|
||||
#
|
||||
@@ -35,38 +46,66 @@ class CircuitTypeSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class CircuitTypeNestedSerializer(CircuitTypeSerializer):
|
||||
class NestedCircuitTypeSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
|
||||
|
||||
class Meta(CircuitTypeSerializer.Meta):
|
||||
pass
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitTerminationSerializer(serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
interface = InterfaceNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info']
|
||||
|
||||
|
||||
class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
provider = ProviderNestedSerializer()
|
||||
type = CircuitTypeNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
terminations = CircuitTerminationSerializer(many=True)
|
||||
class CircuitSerializer(CustomFieldModelSerializer):
|
||||
provider = NestedProviderSerializer()
|
||||
type = NestedCircuitTypeSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'terminations', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
class CircuitNestedSerializer(CircuitSerializer):
|
||||
class NestedCircuitSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
|
||||
|
||||
class Meta(CircuitSerializer.Meta):
|
||||
fields = ['id', 'cid']
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'url', 'cid']
|
||||
|
||||
|
||||
class WritableCircuitSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
||||
|
||||
|
||||
#
|
||||
# Circuit Terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationSerializer(serializers.ModelSerializer):
|
||||
circuit = NestedCircuitSerializer()
|
||||
site = NestedSiteSerializer()
|
||||
interface = InterfaceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
]
|
||||
|
||||
|
||||
class WritableCircuitTerminationSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
]
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
from django.conf.urls import url
|
||||
from rest_framework import routers
|
||||
|
||||
from extras.models import GRAPH_TYPE_PROVIDER
|
||||
from extras.api.views import GraphListView
|
||||
|
||||
from .views import *
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
class CircuitsRootView(routers.APIRootView):
|
||||
"""
|
||||
Circuits API root view
|
||||
"""
|
||||
def get_view_name(self):
|
||||
return 'Circuits'
|
||||
|
||||
# Providers
|
||||
url(r'^providers/$', ProviderListView.as_view(), name='provider_list'),
|
||||
url(r'^providers/(?P<pk>\d+)/$', ProviderDetailView.as_view(), name='provider_detail'),
|
||||
url(r'^providers/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER},
|
||||
name='provider_graphs'),
|
||||
|
||||
# Circuit types
|
||||
url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'),
|
||||
url(r'^circuit-types/(?P<pk>\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'),
|
||||
router = routers.DefaultRouter()
|
||||
router.APIRootView = CircuitsRootView
|
||||
|
||||
# Circuits
|
||||
url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'),
|
||||
url(r'^circuits/(?P<pk>\d+)/$', CircuitDetailView.as_view(), name='circuit_detail'),
|
||||
# Providers
|
||||
router.register(r'providers', views.ProviderViewSet)
|
||||
|
||||
]
|
||||
# Circuits
|
||||
router.register(r'circuit-types', views.CircuitTypeViewSet)
|
||||
router.register(r'circuits', views.CircuitViewSet)
|
||||
router.register(r'circuit-terminations', views.CircuitTerminationViewSet)
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,58 +1,65 @@
|
||||
from rest_framework import generics
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from circuits.models import Provider, CircuitType, Circuit
|
||||
from circuits.filters import CircuitFilter
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from extras.api.views import CustomFieldModelAPIView
|
||||
from circuits import filters
|
||||
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from utilities.api import WritableSerializerMixin
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List all providers
|
||||
"""
|
||||
queryset = Provider.objects.prefetch_related('custom_field_values__field')
|
||||
#
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.all()
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
write_serializer_class = serializers.WritableProviderSerializer
|
||||
filter_class = filters.ProviderFilter
|
||||
|
||||
@detail_route()
|
||||
def graphs(self, request, pk=None):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular provider.
|
||||
"""
|
||||
provider = get_object_or_404(Provider, pk=pk)
|
||||
queryset = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER)
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single provider
|
||||
"""
|
||||
queryset = Provider.objects.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
#
|
||||
# Circuit Types
|
||||
#
|
||||
|
||||
|
||||
class CircuitTypeListView(generics.ListAPIView):
|
||||
"""
|
||||
List all circuit types
|
||||
"""
|
||||
class CircuitTypeViewSet(ModelViewSet):
|
||||
queryset = CircuitType.objects.all()
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
|
||||
|
||||
class CircuitTypeDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single circuit type
|
||||
"""
|
||||
queryset = CircuitType.objects.all()
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
#
|
||||
# Circuits
|
||||
#
|
||||
|
||||
|
||||
class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List circuits (filterable)
|
||||
"""
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
filter_class = CircuitFilter
|
||||
write_serializer_class = serializers.WritableCircuitSerializer
|
||||
filter_class = filters.CircuitFilter
|
||||
|
||||
|
||||
class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single circuit
|
||||
"""
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
#
|
||||
# Circuit Terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
write_serializer_class = serializers.WritableCircuitTerminationSerializer
|
||||
filter_class = filters.CircuitTerminationFilter
|
||||
|
||||
@@ -6,8 +6,7 @@ from dcim.models import Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
|
||||
|
||||
from .models import Provider, Circuit, CircuitType
|
||||
from .models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
|
||||
|
||||
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
@@ -107,3 +106,15 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
Q(description__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
).distinct()
|
||||
|
||||
|
||||
class CircuitTerminationFilter(django_filters.FilterSet):
|
||||
circuit_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='circuit',
|
||||
queryset=Circuit.objects.all(),
|
||||
label='Circuit',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = ['term_side', 'site']
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-04-19 17:17
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0007_circuit_add_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='interface',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface'),
|
||||
),
|
||||
]
|
||||
@@ -150,14 +150,10 @@ class CircuitTermination(models.Model):
|
||||
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
|
||||
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
|
||||
site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
|
||||
interface = models.OneToOneField(
|
||||
'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT
|
||||
)
|
||||
interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True)
|
||||
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
|
||||
upstream_speed = models.PositiveIntegerField(
|
||||
blank=True, null=True, verbose_name='Upstream speed (Kbps)',
|
||||
help_text='Upstream speed, if different from port speed'
|
||||
)
|
||||
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
|
||||
help_text='Upstream speed, if different from port speed')
|
||||
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
|
||||
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
|
||||
|
||||
|
||||
0
netbox/circuits/tests/__init__.py
Normal file
0
netbox/circuits/tests/__init__.py
Normal file
329
netbox/circuits/tests/test_api.py
Normal file
329
netbox/circuits/tests/test_api.py
Normal file
@@ -0,0 +1,329 @@
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
|
||||
from users.models import Token
|
||||
from utilities.tests import HttpStatusMixin
|
||||
|
||||
|
||||
class ProviderTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
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)
|
||||
|
||||
def test_get_provider_graphs(self):
|
||||
|
||||
self.graph1 = Graph.objects.create(
|
||||
type=GRAPH_TYPE_PROVIDER, name='Test Graph 1',
|
||||
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'
|
||||
)
|
||||
self.graph2 = Graph.objects.create(
|
||||
type=GRAPH_TYPE_PROVIDER, name='Test Graph 2',
|
||||
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'
|
||||
)
|
||||
self.graph3 = Graph.objects.create(
|
||||
type=GRAPH_TYPE_PROVIDER, name='Test Graph 3',
|
||||
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'
|
||||
)
|
||||
|
||||
url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=test-provider-1&foo=1')
|
||||
|
||||
def test_list_providers(self):
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_provider(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Provider 4',
|
||||
'slug': 'test-provider-4',
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
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'])
|
||||
|
||||
def test_update_provider(self):
|
||||
|
||||
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, **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(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
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_create_circuittype(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Circuit Type 4',
|
||||
'slug': 'test-circuit-type-4',
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittype-list')
|
||||
response = self.client.post(url, data, **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, **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(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
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_create_circuit(self):
|
||||
|
||||
data = {
|
||||
'cid': 'TEST0004',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.post(url, data, **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_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, **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(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
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.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')
|
||||
self.circuittermination1 = CircuitTermination.objects.create(
|
||||
circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
|
||||
)
|
||||
self.circuittermination2 = CircuitTermination.objects.create(
|
||||
circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
|
||||
)
|
||||
self.circuittermination3 = CircuitTermination.objects.create(
|
||||
circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, 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'], 3)
|
||||
|
||||
def test_create_circuittermination(self):
|
||||
|
||||
data = {
|
||||
'circuit': self.circuit1.pk,
|
||||
'term_side': TERM_SIDE_Z,
|
||||
'site': self.site2.pk,
|
||||
'port_speed': 1000000,
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittermination-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 4)
|
||||
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):
|
||||
|
||||
data = {
|
||||
'circuit': self.circuit1.pk,
|
||||
'term_side': TERM_SIDE_Z,
|
||||
'site': self.site2.pk,
|
||||
'port_speed': 1000000,
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 3)
|
||||
circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(circuittermination1.circuit_id, data['circuit'])
|
||||
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(), 2)
|
||||
@@ -95,7 +95,7 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = CircuitType
|
||||
form_class = forms.CircuitTypeForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('circuits:circuittype_list')
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.change_circuit'
|
||||
model = Circuit
|
||||
form_class = forms.CircuitForm
|
||||
fields_initial = ['provider']
|
||||
template_name = 'circuits/circuit_edit.html'
|
||||
default_return_url = 'circuits:circuit_list'
|
||||
|
||||
@@ -229,6 +230,7 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.change_circuittermination'
|
||||
model = CircuitTermination
|
||||
form_class = forms.CircuitTerminationForm
|
||||
fields_initial = ['term_side']
|
||||
template_name = 'circuits/circuittermination_edit.html'
|
||||
|
||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||
@@ -236,7 +238,7 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return obj.circuit.get_absolute_url()
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from mptt.admin import MPTTModelAdmin
|
||||
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, InventoryItem, Platform,
|
||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region,
|
||||
Site,
|
||||
)
|
||||
@@ -183,8 +183,8 @@ class DeviceBayAdmin(admin.TabularInline):
|
||||
readonly_fields = ['installed_device']
|
||||
|
||||
|
||||
class ModuleAdmin(admin.TabularInline):
|
||||
model = Module
|
||||
class InventoryItemAdmin(admin.TabularInline):
|
||||
model = InventoryItem
|
||||
readonly_fields = ['parent', 'discovered']
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
PowerOutletAdmin,
|
||||
InterfaceAdmin,
|
||||
DeviceBayAdmin,
|
||||
ModuleAdmin,
|
||||
InventoryItemAdmin,
|
||||
]
|
||||
list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
|
||||
'serial']
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from ipam.models import IPAddress
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
|
||||
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
|
||||
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_FRONT,
|
||||
RACK_FACE_REAR, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
|
||||
CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
|
||||
DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
|
||||
InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
|
||||
RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
||||
)
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionNestedSerializer(serializers.ModelSerializer):
|
||||
class NestedRegionSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class RegionSerializer(serializers.ModelSerializer):
|
||||
parent = NestedRegionSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = ['id', 'name', 'slug', 'parent']
|
||||
|
||||
|
||||
class WritableRegionSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
@@ -33,21 +45,35 @@ class RegionSerializer(serializers.ModelSerializer):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
region = RegionNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
class SiteSerializer(CustomFieldModelSerializer):
|
||||
region = NestedRegionSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
|
||||
'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
|
||||
'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
|
||||
]
|
||||
|
||||
|
||||
class SiteNestedSerializer(SiteSerializer):
|
||||
class NestedSiteSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
|
||||
|
||||
class Meta(SiteSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class WritableSiteSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@@ -55,17 +81,26 @@ class SiteNestedSerializer(SiteSerializer):
|
||||
#
|
||||
|
||||
class RackGroupSerializer(serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
site = NestedSiteSerializer()
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ['id', 'name', 'slug', 'site']
|
||||
|
||||
|
||||
class RackGroupNestedSerializer(RackGroupSerializer):
|
||||
class NestedRackGroupSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
|
||||
|
||||
class Meta(SiteSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class WritableRackGroupSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ['id', 'name', 'slug', 'site']
|
||||
|
||||
|
||||
#
|
||||
@@ -79,61 +114,87 @@ class RackRoleSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class RackRoleNestedSerializer(RackRoleSerializer):
|
||||
class NestedRackRoleSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
|
||||
|
||||
class Meta(RackRoleSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackReservationNestedSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'units', 'created', 'user', 'description']
|
||||
|
||||
|
||||
class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
group = RackGroupNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
role = RackRoleNestedSerializer()
|
||||
class RackSerializer(CustomFieldModelSerializer):
|
||||
site = NestedSiteSerializer()
|
||||
group = NestedRackGroupSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
role = NestedRackRoleSerializer()
|
||||
type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES)
|
||||
width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
|
||||
'u_height', 'desc_units', 'comments', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height',
|
||||
'desc_units', 'comments', 'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
class RackNestedSerializer(RackSerializer):
|
||||
class NestedRackSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
|
||||
|
||||
class Meta(RackSerializer.Meta):
|
||||
fields = ['id', 'name', 'facility_id', 'display_name']
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['id', 'url', 'name', 'display_name']
|
||||
|
||||
|
||||
class RackDetailSerializer(RackSerializer):
|
||||
front_units = serializers.SerializerMethodField()
|
||||
rear_units = serializers.SerializerMethodField()
|
||||
reservations = RackReservationNestedSerializer(many=True)
|
||||
class WritableRackSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(RackSerializer.Meta):
|
||||
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
|
||||
'u_height', 'desc_units', 'reservations', 'comments', 'custom_fields', 'front_units', 'rear_units']
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
|
||||
'comments',
|
||||
]
|
||||
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
|
||||
# prevents facility_id from being interpreted as a required field.
|
||||
validators = [
|
||||
UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'name'))
|
||||
]
|
||||
|
||||
def get_front_units(self, obj):
|
||||
units = obj.get_rack_units(face=RACK_FACE_FRONT)
|
||||
for u in units:
|
||||
u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None
|
||||
return units
|
||||
def validate(self, data):
|
||||
|
||||
def get_rear_units(self, obj):
|
||||
units = obj.get_rack_units(face=RACK_FACE_REAR)
|
||||
for u in units:
|
||||
u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None
|
||||
return units
|
||||
# Validate uniqueness of (site, facility_id) since we omitted the automatically-created validator from Meta.
|
||||
if data.get('facility_id', None):
|
||||
validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'facility_id'))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
#
|
||||
# Rack units
|
||||
#
|
||||
|
||||
class NestedDeviceSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['id', 'url', 'name', 'display_name']
|
||||
|
||||
|
||||
class RackUnitSerializer(serializers.Serializer):
|
||||
"""
|
||||
A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
|
||||
"""
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
name = serializers.CharField(read_only=True)
|
||||
face = serializers.IntegerField(read_only=True)
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
|
||||
|
||||
#
|
||||
@@ -141,13 +202,20 @@ class RackDetailSerializer(RackSerializer):
|
||||
#
|
||||
|
||||
class RackReservationSerializer(serializers.ModelSerializer):
|
||||
rack = RackNestedSerializer()
|
||||
rack = NestedRackSerializer()
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'rack', 'units', 'created', 'user', 'description']
|
||||
|
||||
|
||||
class WritableRackReservationSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'rack', 'units', 'description']
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
@@ -159,88 +227,165 @@ class ManufacturerSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class ManufacturerNestedSerializer(ManufacturerSerializer):
|
||||
class NestedManufacturerSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
|
||||
|
||||
class Meta(ManufacturerSerializer.Meta):
|
||||
pass
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Device types
|
||||
#
|
||||
|
||||
class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
manufacturer = ManufacturerNestedSerializer()
|
||||
subdevice_role = serializers.SerializerMethodField()
|
||||
class DeviceTypeSerializer(CustomFieldModelSerializer):
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES)
|
||||
subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES)
|
||||
instance_count = serializers.IntegerField(source='instances.count', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
|
||||
'comments', 'custom_fields', 'instance_count']
|
||||
|
||||
def get_subdevice_role(self, obj):
|
||||
return {
|
||||
SUBDEVICE_ROLE_PARENT: 'parent',
|
||||
SUBDEVICE_ROLE_CHILD: 'child',
|
||||
None: None,
|
||||
}[obj.subdevice_role]
|
||||
fields = [
|
||||
'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
|
||||
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
|
||||
'instance_count',
|
||||
]
|
||||
|
||||
|
||||
class DeviceTypeNestedSerializer(DeviceTypeSerializer):
|
||||
class NestedDeviceTypeSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
|
||||
class Meta(DeviceTypeSerializer.Meta):
|
||||
fields = ['id', 'manufacturer', 'model', 'slug']
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['id', 'url', 'manufacturer', 'model', 'slug']
|
||||
|
||||
|
||||
class ConsolePortTemplateNestedSerializer(serializers.ModelSerializer):
|
||||
class WritableDeviceTypeSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
|
||||
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Console port templates
|
||||
#
|
||||
|
||||
class ConsolePortTemplateSerializer(serializers.ModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateNestedSerializer(serializers.ModelSerializer):
|
||||
class WritableConsolePortTemplateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Console server port templates
|
||||
#
|
||||
|
||||
class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
class PowerPortTemplateNestedSerializer(serializers.ModelSerializer):
|
||||
class WritableConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Power port templates
|
||||
#
|
||||
|
||||
class PowerPortTemplateSerializer(serializers.ModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
class PowerOutletTemplateNestedSerializer(serializers.ModelSerializer):
|
||||
class WritablePowerPortTemplateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Power outlet templates
|
||||
#
|
||||
|
||||
class PowerOutletTemplateSerializer(serializers.ModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = ['id', 'name']
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
class InterfaceTemplateNestedSerializer(serializers.ModelSerializer):
|
||||
class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
#
|
||||
# Interface templates
|
||||
#
|
||||
|
||||
class InterfaceTemplateSerializer(serializers.ModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = ['id', 'name', 'form_factor', 'mgmt_only']
|
||||
fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
|
||||
|
||||
|
||||
class DeviceTypeDetailSerializer(DeviceTypeSerializer):
|
||||
console_port_templates = ConsolePortTemplateNestedSerializer(many=True, read_only=True)
|
||||
cs_port_templates = ConsoleServerPortTemplateNestedSerializer(many=True, read_only=True)
|
||||
power_port_templates = PowerPortTemplateNestedSerializer(many=True, read_only=True)
|
||||
power_outlet_templates = PowerPortTemplateNestedSerializer(many=True, read_only=True)
|
||||
interface_templates = InterfaceTemplateNestedSerializer(many=True, read_only=True)
|
||||
class WritableInterfaceTemplateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(DeviceTypeSerializer.Meta):
|
||||
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
|
||||
'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates',
|
||||
'power_outlet_templates', 'interface_templates']
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
|
||||
|
||||
|
||||
#
|
||||
# Device bay templates
|
||||
#
|
||||
|
||||
class DeviceBayTemplateSerializer(serializers.ModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
|
||||
|
||||
#
|
||||
@@ -254,10 +399,12 @@ class DeviceRoleSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class DeviceRoleNestedSerializer(DeviceRoleSerializer):
|
||||
class NestedDeviceRoleSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
|
||||
|
||||
class Meta(DeviceRoleSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
@@ -271,34 +418,39 @@ class PlatformSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug', 'rpc_client']
|
||||
|
||||
|
||||
class PlatformNestedSerializer(PlatformSerializer):
|
||||
class NestedPlatformSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
|
||||
|
||||
class Meta(PlatformSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
#
|
||||
|
||||
# Cannot import ipam.api.IPAddressNestedSerializer due to circular dependency
|
||||
class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
|
||||
# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
|
||||
class DeviceIPAddressSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['id', 'family', 'address']
|
||||
fields = ['id', 'url', 'family', 'address']
|
||||
|
||||
|
||||
class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
device_type = DeviceTypeNestedSerializer()
|
||||
device_role = DeviceRoleNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
platform = PlatformNestedSerializer()
|
||||
site = SiteNestedSerializer()
|
||||
rack = RackNestedSerializer()
|
||||
primary_ip = DeviceIPAddressNestedSerializer()
|
||||
primary_ip4 = DeviceIPAddressNestedSerializer()
|
||||
primary_ip6 = DeviceIPAddressNestedSerializer()
|
||||
class DeviceSerializer(CustomFieldModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_role = NestedDeviceRoleSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
platform = NestedPlatformSerializer()
|
||||
site = NestedSiteSerializer()
|
||||
rack = NestedRackSerializer()
|
||||
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
|
||||
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
|
||||
primary_ip = DeviceIPAddressSerializer()
|
||||
primary_ip4 = DeviceIPAddressSerializer()
|
||||
primary_ip6 = DeviceIPAddressSerializer()
|
||||
parent_device = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@@ -324,11 +476,25 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
}
|
||||
|
||||
|
||||
class DeviceNestedSerializer(serializers.ModelSerializer):
|
||||
class WritableDeviceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['id', 'name', 'display_name']
|
||||
fields = [
|
||||
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments',
|
||||
]
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta.
|
||||
if data.get('rack') and data.get('position') and data.get('face'):
|
||||
validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('rack', 'position', 'face'))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
#
|
||||
@@ -336,16 +502,18 @@ class DeviceNestedSerializer(serializers.ModelSerializer):
|
||||
#
|
||||
|
||||
class ConsoleServerPortSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
device = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['id', 'device', 'name', 'connected_console']
|
||||
read_only_fields = ['connected_console']
|
||||
|
||||
|
||||
class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer):
|
||||
class WritableConsoleServerPortSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(ConsoleServerPortSerializer.Meta):
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['id', 'device', 'name']
|
||||
|
||||
|
||||
@@ -354,18 +522,19 @@ class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer):
|
||||
#
|
||||
|
||||
class ConsolePortSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
cs_port = ConsoleServerPortNestedSerializer()
|
||||
device = NestedDeviceSerializer()
|
||||
cs_port = ConsoleServerPortSerializer()
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
|
||||
|
||||
|
||||
class ConsolePortNestedSerializer(ConsolePortSerializer):
|
||||
class WritableConsolePortSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(ConsolePortSerializer.Meta):
|
||||
fields = ['id', 'device', 'name']
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
|
||||
|
||||
|
||||
#
|
||||
@@ -373,16 +542,18 @@ class ConsolePortNestedSerializer(ConsolePortSerializer):
|
||||
#
|
||||
|
||||
class PowerOutletSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
device = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['id', 'device', 'name', 'connected_port']
|
||||
read_only_fields = ['connected_port']
|
||||
|
||||
|
||||
class PowerOutletNestedSerializer(PowerOutletSerializer):
|
||||
class WritablePowerOutletSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(PowerOutletSerializer.Meta):
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['id', 'device', 'name']
|
||||
|
||||
|
||||
@@ -391,104 +562,103 @@ class PowerOutletNestedSerializer(PowerOutletSerializer):
|
||||
#
|
||||
|
||||
class PowerPortSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
power_outlet = PowerOutletNestedSerializer()
|
||||
device = NestedDeviceSerializer()
|
||||
power_outlet = PowerOutletSerializer()
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
|
||||
|
||||
|
||||
class PowerPortNestedSerializer(PowerPortSerializer):
|
||||
class WritablePowerPortSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(PowerPortSerializer.Meta):
|
||||
fields = ['id', 'device', 'name']
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class LAGInterfaceNestedSerializer(serializers.ModelSerializer):
|
||||
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'name', 'form_factor']
|
||||
|
||||
|
||||
class InterfaceSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
|
||||
lag = LAGInterfaceNestedSerializer()
|
||||
device = NestedDeviceSerializer()
|
||||
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
||||
connection = serializers.SerializerMethodField(read_only=True)
|
||||
connected_interface = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceNestedSerializer(InterfaceSerializer):
|
||||
form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
|
||||
|
||||
class Meta(InterfaceSerializer.Meta):
|
||||
fields = ['id', 'device', 'name']
|
||||
|
||||
|
||||
class InterfaceDetailSerializer(InterfaceSerializer):
|
||||
connected_interface = InterfaceSerializer()
|
||||
|
||||
class Meta(InterfaceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected',
|
||||
'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'connection',
|
||||
'connected_interface',
|
||||
]
|
||||
|
||||
def get_connection(self, obj):
|
||||
if obj.connection:
|
||||
return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data
|
||||
return None
|
||||
|
||||
def get_connected_interface(self, obj):
|
||||
if obj.connected_interface:
|
||||
return PeerInterfaceSerializer(obj.connected_interface, context=self.context).data
|
||||
return None
|
||||
|
||||
|
||||
class PeerInterfaceSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
|
||||
|
||||
|
||||
class WritableInterfaceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
class DeviceBaySerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
device = NestedDeviceSerializer()
|
||||
installed_device = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['id', 'device', 'name']
|
||||
fields = ['id', 'device', 'name', 'installed_device']
|
||||
|
||||
|
||||
class DeviceBayNestedSerializer(DeviceBaySerializer):
|
||||
installed_device = DeviceNestedSerializer()
|
||||
class WritableDeviceBaySerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(DeviceBaySerializer.Meta):
|
||||
fields = ['id', 'name', 'installed_device']
|
||||
|
||||
|
||||
class DeviceBayDetailSerializer(DeviceBaySerializer):
|
||||
installed_device = DeviceNestedSerializer()
|
||||
|
||||
class Meta(DeviceBaySerializer.Meta):
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['id', 'device', 'name', 'installed_device']
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
class ModuleSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
manufacturer = ManufacturerNestedSerializer()
|
||||
class InventoryItemSerializer(serializers.ModelSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
model = InventoryItem
|
||||
fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
|
||||
|
||||
|
||||
class ModuleNestedSerializer(ModuleSerializer):
|
||||
class WritableInventoryItemSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(ModuleSerializer.Meta):
|
||||
fields = ['id', 'device', 'parent', 'name']
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
|
||||
|
||||
|
||||
#
|
||||
@@ -496,6 +666,24 @@ class ModuleNestedSerializer(ModuleSerializer):
|
||||
#
|
||||
|
||||
class InterfaceConnectionSerializer(serializers.ModelSerializer):
|
||||
interface_a = PeerInterfaceSerializer()
|
||||
interface_b = PeerInterfaceSerializer()
|
||||
connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
fields = ['id', 'interface_a', 'interface_b', 'connection_status']
|
||||
|
||||
|
||||
class NestedInterfaceConnectionSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail')
|
||||
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
fields = ['id', 'url', 'connection_status']
|
||||
|
||||
|
||||
class WritableInterfaceConnectionSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
|
||||
@@ -1,84 +1,59 @@
|
||||
from django.conf.urls import url
|
||||
from rest_framework import routers
|
||||
|
||||
from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from extras.api.views import GraphListView, TopologyMapView
|
||||
|
||||
from .views import *
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
class DCIMRootView(routers.APIRootView):
|
||||
"""
|
||||
DCIM API root view
|
||||
"""
|
||||
def get_view_name(self):
|
||||
return 'DCIM'
|
||||
|
||||
# Regions
|
||||
url(r'^regions/$', RegionListView.as_view(), name='region_list'),
|
||||
url(r'^regions/(?P<pk>\d+)/$', RegionDetailView.as_view(), name='region_detail'),
|
||||
|
||||
# Sites
|
||||
url(r'^sites/$', SiteListView.as_view(), name='site_list'),
|
||||
url(r'^sites/(?P<pk>\d+)/$', SiteDetailView.as_view(), name='site_detail'),
|
||||
url(r'^sites/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'),
|
||||
url(r'^sites/(?P<site>\d+)/racks/$', RackListView.as_view(), name='site_racks'),
|
||||
router = routers.DefaultRouter()
|
||||
router.APIRootView = DCIMRootView
|
||||
|
||||
# Rack groups
|
||||
url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'),
|
||||
url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
|
||||
# Sites
|
||||
router.register(r'regions', views.RegionViewSet)
|
||||
router.register(r'sites', views.SiteViewSet)
|
||||
|
||||
# Rack roles
|
||||
url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'),
|
||||
url(r'^rack-roles/(?P<pk>\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'),
|
||||
# Racks
|
||||
router.register(r'rack-groups', views.RackGroupViewSet)
|
||||
router.register(r'rack-roles', views.RackRoleViewSet)
|
||||
router.register(r'racks', views.RackViewSet)
|
||||
router.register(r'rack-reservations', views.RackReservationViewSet)
|
||||
|
||||
# Racks
|
||||
url(r'^racks/$', RackListView.as_view(), name='rack_list'),
|
||||
url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
|
||||
url(r'^racks/(?P<pk>\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'),
|
||||
# Device types
|
||||
router.register(r'manufacturers', views.ManufacturerViewSet)
|
||||
router.register(r'device-types', views.DeviceTypeViewSet)
|
||||
|
||||
# Rack reservations
|
||||
url(r'^rack-reservations/$', RackReservationListView.as_view(), name='rackreservation_list'),
|
||||
url(r'^rack-reservations/(?P<pk>\d+)/$', RackReservationDetailView.as_view(), name='rackreservation_detail'),
|
||||
# Device type components
|
||||
router.register(r'console-port-templates', views.ConsolePortTemplateViewSet)
|
||||
router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
|
||||
router.register(r'power-port-templates', views.PowerPortTemplateViewSet)
|
||||
router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet)
|
||||
router.register(r'interface-templates', views.InterfaceTemplateViewSet)
|
||||
router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet)
|
||||
|
||||
# Manufacturers
|
||||
url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'),
|
||||
url(r'^manufacturers/(?P<pk>\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'),
|
||||
# Devices
|
||||
router.register(r'device-roles', views.DeviceRoleViewSet)
|
||||
router.register(r'platforms', views.PlatformViewSet)
|
||||
router.register(r'devices', views.DeviceViewSet)
|
||||
|
||||
# Device types
|
||||
url(r'^device-types/$', DeviceTypeListView.as_view(), name='devicetype_list'),
|
||||
url(r'^device-types/(?P<pk>\d+)/$', DeviceTypeDetailView.as_view(), name='devicetype_detail'),
|
||||
# Device components
|
||||
router.register(r'console-ports', views.ConsolePortViewSet)
|
||||
router.register(r'console-server-ports', views.ConsoleServerPortViewSet)
|
||||
router.register(r'power-ports', views.PowerPortViewSet)
|
||||
router.register(r'power-outlets', views.PowerOutletViewSet)
|
||||
router.register(r'interfaces', views.InterfaceViewSet)
|
||||
router.register(r'device-bays', views.DeviceBayViewSet)
|
||||
router.register(r'inventory-items', views.InventoryItemViewSet)
|
||||
|
||||
# Device roles
|
||||
url(r'^device-roles/$', DeviceRoleListView.as_view(), name='devicerole_list'),
|
||||
url(r'^device-roles/(?P<pk>\d+)/$', DeviceRoleDetailView.as_view(), name='devicerole_detail'),
|
||||
# Interface connections
|
||||
router.register(r'interface-connections', views.InterfaceConnectionViewSet)
|
||||
|
||||
# Platforms
|
||||
url(r'^platforms/$', PlatformListView.as_view(), name='platform_list'),
|
||||
url(r'^platforms/(?P<pk>\d+)/$', PlatformDetailView.as_view(), name='platform_detail'),
|
||||
# Miscellaneous
|
||||
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
|
||||
|
||||
# Devices
|
||||
url(r'^devices/$', DeviceListView.as_view(), name='device_list'),
|
||||
url(r'^devices/(?P<pk>\d+)/$', DeviceDetailView.as_view(), name='device_detail'),
|
||||
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/$', ConsolePortListView.as_view(), name='device_consoleports'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/$', ConsoleServerPortListView.as_view(),
|
||||
name='device_consoleserverports'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
|
||||
url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
|
||||
url(r'^devices/(?P<pk>\d+)/modules/$', ModuleListView.as_view(), name='device_modules'),
|
||||
|
||||
# Console ports
|
||||
url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
|
||||
|
||||
# Power ports
|
||||
url(r'^power-ports/(?P<pk>\d+)/$', PowerPortView.as_view(), name='powerport'),
|
||||
|
||||
# Interfaces
|
||||
url(r'^interfaces/(?P<pk>\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE},
|
||||
name='interface_graphs'),
|
||||
url(r'^interface-connections/$', InterfaceConnectionListView.as_view(), name='interfaceconnection_list'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'),
|
||||
|
||||
# Miscellaneous
|
||||
url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'),
|
||||
url(r'^topology-maps/(?P<slug>[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'),
|
||||
|
||||
]
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
from rest_framework import generics
|
||||
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
|
||||
from rest_framework.decorators import detail_route, list_route
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ViewSet
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection,
|
||||
Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
|
||||
VIRTUAL_IFACE_TYPES,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
|
||||
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
|
||||
RackRole, Region, Site,
|
||||
)
|
||||
from dcim import filters
|
||||
from extras.api.views import CustomFieldModelAPIView
|
||||
from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer
|
||||
from utilities.api import ServiceUnavailable
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from utilities.api import ServiceUnavailable, WritableSerializerMixin
|
||||
from .exceptions import MissingFilterException
|
||||
from . import serializers
|
||||
|
||||
@@ -26,79 +25,49 @@ from . import serializers
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionListView(generics.ListAPIView):
|
||||
"""
|
||||
List all regions
|
||||
"""
|
||||
queryset = Region.objects.all()
|
||||
serializer_class = serializers.RegionSerializer
|
||||
|
||||
|
||||
class RegionDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single region
|
||||
"""
|
||||
class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = Region.objects.all()
|
||||
serializer_class = serializers.RegionSerializer
|
||||
write_serializer_class = serializers.WritableRegionSerializer
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List all sites
|
||||
"""
|
||||
queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
|
||||
class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = Site.objects.select_related('region', 'tenant')
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filter_class = filters.SiteFilter
|
||||
write_serializer_class = serializers.WritableSiteSerializer
|
||||
|
||||
|
||||
class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single site
|
||||
"""
|
||||
queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.SiteSerializer
|
||||
@detail_route()
|
||||
def graphs(self, request, pk=None):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular site.
|
||||
"""
|
||||
site = get_object_or_404(Site, pk=pk)
|
||||
queryset = Graph.objects.filter(type=GRAPH_TYPE_SITE)
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
#
|
||||
# Rack groups
|
||||
#
|
||||
|
||||
class RackGroupListView(generics.ListAPIView):
|
||||
"""
|
||||
List all rack groups
|
||||
"""
|
||||
class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = RackGroup.objects.select_related('site')
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
filter_class = filters.RackGroupFilter
|
||||
|
||||
|
||||
class RackGroupDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single rack group
|
||||
"""
|
||||
queryset = RackGroup.objects.select_related('site')
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
write_serializer_class = serializers.WritableRackGroupSerializer
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleListView(generics.ListAPIView):
|
||||
"""
|
||||
List all rack roles
|
||||
"""
|
||||
queryset = RackRole.objects.all()
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
|
||||
|
||||
class RackRoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single rack role
|
||||
"""
|
||||
class RackRoleViewSet(ModelViewSet):
|
||||
queryset = RackRole.objects.all()
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
|
||||
@@ -107,36 +76,17 @@ class RackRoleDetailView(generics.RetrieveAPIView):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List racks (filterable)
|
||||
"""
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
|
||||
serializer_class = serializers.RackSerializer
|
||||
write_serializer_class = serializers.WritableRackSerializer
|
||||
filter_class = filters.RackFilter
|
||||
|
||||
|
||||
class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single rack
|
||||
"""
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.RackDetailSerializer
|
||||
|
||||
|
||||
#
|
||||
# Rack units
|
||||
#
|
||||
|
||||
class RackUnitListView(APIView):
|
||||
"""
|
||||
List rack units (by rack)
|
||||
"""
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@detail_route()
|
||||
def units(self, request, pk=None):
|
||||
"""
|
||||
List rack units (by rack)
|
||||
"""
|
||||
rack = get_object_or_404(Rack, pk=pk)
|
||||
face = request.GET.get('face', 0)
|
||||
exclude_pk = request.GET.get('exclude', None)
|
||||
@@ -147,92 +97,97 @@ class RackUnitListView(APIView):
|
||||
exclude_pk = None
|
||||
elevation = rack.get_rack_units(face, exclude_pk)
|
||||
|
||||
# Serialize Devices within the rack elevation
|
||||
for u in elevation:
|
||||
if u['device']:
|
||||
u['device'] = serializers.DeviceNestedSerializer(instance=u['device']).data
|
||||
|
||||
return Response(elevation)
|
||||
page = self.paginate_queryset(elevation)
|
||||
if page is not None:
|
||||
rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
|
||||
return self.get_paginated_response(rack_units.data)
|
||||
|
||||
|
||||
#
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationListView(generics.ListAPIView):
|
||||
"""
|
||||
List all rack reservation
|
||||
"""
|
||||
queryset = RackReservation.objects.all()
|
||||
class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = RackReservation.objects.select_related('rack')
|
||||
serializer_class = serializers.RackReservationSerializer
|
||||
write_serializer_class = serializers.WritableRackReservationSerializer
|
||||
filter_class = filters.RackReservationFilter
|
||||
|
||||
|
||||
class RackReservationDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single rack reservation
|
||||
"""
|
||||
queryset = RackReservation.objects.all()
|
||||
serializer_class = serializers.RackReservationSerializer
|
||||
# Assign user from request
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
|
||||
class ManufacturerListView(generics.ListAPIView):
|
||||
"""
|
||||
List all hardware manufacturers
|
||||
"""
|
||||
queryset = Manufacturer.objects.all()
|
||||
serializer_class = serializers.ManufacturerSerializer
|
||||
|
||||
|
||||
class ManufacturerDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single hardware manufacturers
|
||||
"""
|
||||
class ManufacturerViewSet(ModelViewSet):
|
||||
queryset = Manufacturer.objects.all()
|
||||
serializer_class = serializers.ManufacturerSerializer
|
||||
|
||||
|
||||
#
|
||||
# Device Types
|
||||
# Device types
|
||||
#
|
||||
|
||||
class DeviceTypeListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List device types (filterable)
|
||||
"""
|
||||
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
|
||||
class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = DeviceType.objects.select_related('manufacturer')
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filter_class = filters.DeviceTypeFilter
|
||||
write_serializer_class = serializers.WritableDeviceTypeSerializer
|
||||
|
||||
|
||||
class DeviceTypeDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single device type
|
||||
"""
|
||||
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.DeviceTypeDetailSerializer
|
||||
#
|
||||
# Device type components
|
||||
#
|
||||
|
||||
class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ConsolePortTemplateSerializer
|
||||
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
|
||||
filter_class = filters.ConsolePortTemplateFilter
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ConsoleServerPortTemplateSerializer
|
||||
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
|
||||
filter_class = filters.ConsoleServerPortTemplateFilter
|
||||
|
||||
|
||||
class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.PowerPortTemplateSerializer
|
||||
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
|
||||
filter_class = filters.PowerPortTemplateFilter
|
||||
|
||||
|
||||
class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.PowerOutletTemplateSerializer
|
||||
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
|
||||
filter_class = filters.PowerOutletTemplateFilter
|
||||
|
||||
|
||||
class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.InterfaceTemplateSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
|
||||
filter_class = filters.InterfaceTemplateFilter
|
||||
|
||||
|
||||
class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.DeviceBayTemplateSerializer
|
||||
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
|
||||
filter_class = filters.DeviceBayTemplateFilter
|
||||
|
||||
|
||||
#
|
||||
# Device roles
|
||||
#
|
||||
|
||||
class DeviceRoleListView(generics.ListAPIView):
|
||||
"""
|
||||
List all device roles
|
||||
"""
|
||||
queryset = DeviceRole.objects.all()
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
|
||||
|
||||
class DeviceRoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single device role
|
||||
"""
|
||||
class DeviceRoleViewSet(ModelViewSet):
|
||||
queryset = DeviceRole.objects.all()
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
|
||||
@@ -241,18 +196,7 @@ class DeviceRoleDetailView(generics.RetrieveAPIView):
|
||||
# Platforms
|
||||
#
|
||||
|
||||
class PlatformListView(generics.ListAPIView):
|
||||
"""
|
||||
List all platforms
|
||||
"""
|
||||
queryset = Platform.objects.all()
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
|
||||
|
||||
class PlatformDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single platform
|
||||
"""
|
||||
class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.all()
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
|
||||
@@ -261,284 +205,142 @@ class PlatformDetailView(generics.RetrieveAPIView):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List devices (filterable)
|
||||
"""
|
||||
class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = Device.objects.select_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay'
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
).prefetch_related(
|
||||
'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'custom_field_values__field'
|
||||
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
|
||||
)
|
||||
serializer_class = serializers.DeviceSerializer
|
||||
write_serializer_class = serializers.WritableDeviceSerializer
|
||||
filter_class = filters.DeviceFilter
|
||||
renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
|
||||
|
||||
|
||||
class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single device
|
||||
"""
|
||||
queryset = Device.objects.select_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay'
|
||||
).prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.DeviceSerializer
|
||||
|
||||
|
||||
#
|
||||
# Console ports
|
||||
#
|
||||
|
||||
class ConsolePortListView(generics.ListAPIView):
|
||||
"""
|
||||
List console ports (by device)
|
||||
"""
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
return ConsolePort.objects.filter(device=device).select_related('cs_port')
|
||||
|
||||
|
||||
class ConsolePortView(generics.RetrieveUpdateDestroyAPIView):
|
||||
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
queryset = ConsolePort.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Console server ports
|
||||
#
|
||||
|
||||
class ConsoleServerPortListView(generics.ListAPIView):
|
||||
"""
|
||||
List console server ports (by device)
|
||||
"""
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
return ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
|
||||
|
||||
|
||||
#
|
||||
# Power ports
|
||||
#
|
||||
|
||||
class PowerPortListView(generics.ListAPIView):
|
||||
"""
|
||||
List power ports (by device)
|
||||
"""
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
return PowerPort.objects.filter(device=device).select_related('power_outlet')
|
||||
|
||||
|
||||
class PowerPortView(generics.RetrieveUpdateDestroyAPIView):
|
||||
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
queryset = PowerPort.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Power outlets
|
||||
#
|
||||
|
||||
class PowerOutletListView(generics.ListAPIView):
|
||||
"""
|
||||
List power outlets (by device)
|
||||
"""
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
return PowerOutlet.objects.filter(device=device).select_related('connected_port')
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class InterfaceListView(generics.ListAPIView):
|
||||
"""
|
||||
List interfaces (by device)
|
||||
"""
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filter_class = filters.InterfaceFilter
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
queryset = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
|
||||
.select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
|
||||
|
||||
# Filter by type (physical or virtual)
|
||||
iface_type = self.request.query_params.get('type')
|
||||
if iface_type == 'physical':
|
||||
queryset = queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
|
||||
elif iface_type == 'virtual':
|
||||
queryset = queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
|
||||
elif iface_type is not None:
|
||||
queryset = queryset.empty()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class InterfaceDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single interface
|
||||
"""
|
||||
queryset = Interface.objects.select_related('device')
|
||||
serializer_class = serializers.InterfaceDetailSerializer
|
||||
|
||||
|
||||
class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView):
|
||||
permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
queryset = InterfaceConnection.objects.all()
|
||||
|
||||
|
||||
class InterfaceConnectionListView(generics.ListAPIView):
|
||||
"""
|
||||
Retrieve a list of all interface connections
|
||||
"""
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
queryset = InterfaceConnection.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
|
||||
class DeviceBayListView(generics.ListAPIView):
|
||||
"""
|
||||
List device bays (by device)
|
||||
"""
|
||||
serializer_class = serializers.DeviceBayNestedSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
return DeviceBay.objects.filter(device=device).select_related('installed_device')
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
|
||||
class ModuleListView(generics.ListAPIView):
|
||||
"""
|
||||
List device modules (by device)
|
||||
"""
|
||||
serializer_class = serializers.ModuleSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
device = get_object_or_404(Device, pk=self.kwargs['pk'])
|
||||
return Module.objects.filter(device=device).select_related('device', 'manufacturer')
|
||||
|
||||
|
||||
#
|
||||
# Live queries
|
||||
#
|
||||
|
||||
class LLDPNeighborsView(APIView):
|
||||
"""
|
||||
Retrieve live LLDP neighbors of a device
|
||||
"""
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@detail_route(url_path='lldp-neighbors')
|
||||
def lldp_neighbors(self, request, pk):
|
||||
"""
|
||||
Retrieve live LLDP neighbors of a device
|
||||
"""
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
if not device.primary_ip:
|
||||
raise ServiceUnavailable(detail="No IP configured for this device.")
|
||||
raise ServiceUnavailable("No IP configured for this device.")
|
||||
|
||||
RPC = device.get_rpc_client()
|
||||
if not RPC:
|
||||
raise ServiceUnavailable(detail="No RPC client available for this platform ({}).".format(device.platform))
|
||||
raise ServiceUnavailable("No RPC client available for this platform ({}).".format(device.platform))
|
||||
|
||||
# Connect to device and retrieve inventory info
|
||||
try:
|
||||
with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client:
|
||||
lldp_neighbors = rpc_client.get_lldp_neighbors()
|
||||
except:
|
||||
raise ServiceUnavailable(detail="Error connecting to the remote device.")
|
||||
raise ServiceUnavailable("Error connecting to the remote device.")
|
||||
|
||||
return Response(lldp_neighbors)
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
write_serializer_class = serializers.WritableConsolePortSerializer
|
||||
filter_class = filters.ConsolePortFilter
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
write_serializer_class = serializers.WritableConsoleServerPortSerializer
|
||||
filter_class = filters.ConsoleServerPortFilter
|
||||
|
||||
|
||||
class PowerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
write_serializer_class = serializers.WritablePowerPortSerializer
|
||||
filter_class = filters.PowerPortFilter
|
||||
|
||||
|
||||
class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
write_serializer_class = serializers.WritablePowerOutletSerializer
|
||||
filter_class = filters.PowerOutletFilter
|
||||
|
||||
|
||||
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = Interface.objects.select_related('device')
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceSerializer
|
||||
filter_class = filters.InterfaceFilter
|
||||
|
||||
@detail_route()
|
||||
def graphs(self, request, pk=None):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular interface.
|
||||
"""
|
||||
interface = get_object_or_404(Interface, pk=pk)
|
||||
queryset = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE)
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = DeviceBay.objects.select_related('installed_device')
|
||||
serializer_class = serializers.DeviceBaySerializer
|
||||
write_serializer_class = serializers.WritableDeviceBaySerializer
|
||||
filter_class = filters.DeviceBayFilter
|
||||
|
||||
|
||||
class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
write_serializer_class = serializers.WritableInventoryItemSerializer
|
||||
filter_class = filters.InventoryItemFilter
|
||||
|
||||
|
||||
#
|
||||
# Interface connections
|
||||
#
|
||||
|
||||
class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
|
||||
|
||||
|
||||
#
|
||||
# Miscellaneous
|
||||
#
|
||||
|
||||
class RelatedConnectionsView(APIView):
|
||||
class ConnectedDeviceViewSet(ViewSet):
|
||||
"""
|
||||
Retrieve all connections related to a given console/power/interface connection
|
||||
This endpoint allows a user to determine what device (if any) is connected to a given peer device and peer
|
||||
interface. This is useful in a situation where a device boots with no configuration, but can detect its neighbors
|
||||
via a protocol such as LLDP. Two query parameters must be included in the request:
|
||||
|
||||
* `peer-device`: The name of the peer device
|
||||
* `peer-interface`: The name of the peer interface
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def __init__(self):
|
||||
super(RelatedConnectionsView, self).__init__()
|
||||
def get_view_name(self):
|
||||
return "Connected Device Locator"
|
||||
|
||||
# Custom fields
|
||||
self.content_type = ContentType.objects.get_for_model(Device)
|
||||
self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
|
||||
def list(self, request):
|
||||
|
||||
def get(self, request):
|
||||
peer_device_name = request.query_params.get('peer-device')
|
||||
peer_interface_name = request.query_params.get('peer-interface')
|
||||
if not peer_device_name or not peer_interface_name:
|
||||
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
|
||||
|
||||
peer_device = request.GET.get('peer-device')
|
||||
peer_interface = request.GET.get('peer-interface')
|
||||
# Determine local interface from peer interface's connection
|
||||
peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)
|
||||
local_interface = peer_interface.connected_interface
|
||||
|
||||
# Search by interface
|
||||
if peer_device and peer_interface:
|
||||
if local_interface is None:
|
||||
return Response()
|
||||
|
||||
# Determine local interface from peer interface's connection
|
||||
try:
|
||||
peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface)
|
||||
except Interface.DoesNotExist:
|
||||
raise Http404()
|
||||
local_iface = peer_iface.connected_interface
|
||||
if local_iface:
|
||||
device = local_iface.device
|
||||
else:
|
||||
return Response()
|
||||
|
||||
else:
|
||||
raise MissingFilterException(detail='Must specify search parameters "peer-device" and "peer-interface".')
|
||||
|
||||
# Initialize response skeleton
|
||||
response = {
|
||||
'device': serializers.DeviceSerializer(device, context={'view': self}).data,
|
||||
'console-ports': [],
|
||||
'power-ports': [],
|
||||
'interfaces': [],
|
||||
}
|
||||
|
||||
# Console connections
|
||||
console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device')
|
||||
for cp in console_ports:
|
||||
data = serializers.ConsolePortSerializer(instance=cp).data
|
||||
del(data['device'])
|
||||
response['console-ports'].append(data)
|
||||
|
||||
# Power connections
|
||||
power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device')
|
||||
for pp in power_ports:
|
||||
data = serializers.PowerPortSerializer(instance=pp).data
|
||||
del(data['device'])
|
||||
response['power-ports'].append(data)
|
||||
|
||||
# Interface connections
|
||||
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
|
||||
.select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
|
||||
for iface in interfaces:
|
||||
data = serializers.InterfaceDetailSerializer(instance=iface).data
|
||||
del(data['device'])
|
||||
response['interfaces'].append(data)
|
||||
|
||||
return Response(response)
|
||||
return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data)
|
||||
|
||||
@@ -7,9 +7,10 @@ from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
|
||||
from .models import (
|
||||
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection,
|
||||
Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
|
||||
VIRTUAL_IFACE_TYPES,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate,
|
||||
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,33 +148,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
|
||||
class RackReservationFilter(django_filters.FilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
group_id = NullableModelMultipleChoiceFilter(
|
||||
name='rack__group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
label='Group (ID)',
|
||||
)
|
||||
group = NullableModelMultipleChoiceFilter(
|
||||
name='rack__group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Group',
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='rack',
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -184,16 +158,6 @@ class RackReservationFilter(django_filters.FilterSet):
|
||||
model = RackReservation
|
||||
fields = ['rack', 'user']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(rack__name__icontains=value) |
|
||||
Q(rack__facility_id__icontains=value) |
|
||||
Q(user__username__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
@@ -230,6 +194,62 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeComponentFilterSet(django_filters.FilterSet):
|
||||
devicetype_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device_type',
|
||||
queryset=DeviceType.objects.all(),
|
||||
label='Device type (ID)',
|
||||
)
|
||||
devicetype = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device_type',
|
||||
queryset=DeviceType.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device type (name)',
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerPortTemplateFilter(DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
id__in = NumericInFilter(name='id', lookup_expr='in')
|
||||
q = django_filters.CharFilter(
|
||||
@@ -332,6 +352,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
name='device_type__is_network_device',
|
||||
label='Is a network device',
|
||||
)
|
||||
has_primary_ip = django_filters.BooleanFilter(
|
||||
method='_has_primary_ip',
|
||||
label='Has a primary IP',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@@ -343,7 +367,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(modules__serial__icontains=value.strip()) |
|
||||
Q(inventory_items__serial__icontains=value.strip()) |
|
||||
Q(asset_tag=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
).distinct()
|
||||
@@ -357,8 +381,20 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.filter(
|
||||
Q(primary_ip4__isnull=False) |
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
else:
|
||||
return queryset.exclude(
|
||||
Q(primary_ip4__isnull=False) |
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
|
||||
class ConsolePortFilter(django_filters.FilterSet):
|
||||
|
||||
class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
@@ -371,85 +407,40 @@ class ConsolePortFilter(django_filters.FilterSet):
|
||||
label='Device (name)',
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class ConsoleServerPortFilter(django_filters.FilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
class ConsoleServerPortFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerPortFilter(django_filters.FilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
class PowerPortFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class PowerOutletFilter(django_filters.FilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
class PowerOutletFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class InterfaceFilter(django_filters.FilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
class InterfaceFilter(DeviceComponentFilterSet):
|
||||
type = django_filters.CharFilter(
|
||||
method='filter_type',
|
||||
label='Interface type',
|
||||
)
|
||||
mac_address = django_filters.CharFilter(
|
||||
method='_mac_address',
|
||||
label='MAC address',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
@@ -465,14 +456,19 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
return queryset.filter(form_factor=IFACE_FF_LAG)
|
||||
return queryset
|
||||
|
||||
def _mac_address(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
try:
|
||||
return queryset.filter(mac_address=value)
|
||||
except AddrFormatError:
|
||||
return queryset.none()
|
||||
|
||||
class DeviceBayFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class InventoryItemFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||
@@ -480,58 +476,42 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
method='filter_device',
|
||||
label='Device',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = []
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(cs_port__device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(device__name__icontains=value) |
|
||||
Q(cs_port__device__name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class PowerConnectionFilter(django_filters.FilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
method='filter_device',
|
||||
label='Device',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = []
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(power_outlet__device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(device__name__icontains=value) |
|
||||
Q(power_outlet__device__name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class InterfaceConnectionFilter(django_filters.FilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
label='Site (slug)',
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
method='filter_device',
|
||||
label='Device',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
fields = []
|
||||
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -540,11 +520,3 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
|
||||
Q(interface_a__device__site__slug=value) |
|
||||
Q(interface_b__device__site__slug=value)
|
||||
)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(interface_a__device__name__icontains=value) |
|
||||
Q(interface_b__device__name__icontains=value)
|
||||
)
|
||||
|
||||
@@ -21,9 +21,9 @@ from .models import (
|
||||
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
|
||||
Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
|
||||
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
|
||||
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
|
||||
RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
|
||||
SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES
|
||||
VIRTUAL_IFACE_TYPES
|
||||
)
|
||||
|
||||
|
||||
@@ -330,19 +330,6 @@ class RackReservationForm(BootstrapMixin, forms.ModelForm):
|
||||
return unit_choices
|
||||
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, forms.Form):
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.annotate(filter_count=Count('racks__reservations')),
|
||||
to_field_name='slug'
|
||||
)
|
||||
group_id = FilterChoiceField(
|
||||
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')),
|
||||
label='Rack group',
|
||||
null_option=(0, 'None')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
@@ -388,21 +375,6 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
|
||||
to_field_name='slug'
|
||||
)
|
||||
is_console_server = forms.BooleanField(
|
||||
required=False, label='Is a console server', widget=forms.CheckboxInput(attrs={'value': 'True'}))
|
||||
is_pdu = forms.BooleanField(
|
||||
required=False, label='Is a PDU', widget=forms.CheckboxInput(attrs={'value': 'True'})
|
||||
)
|
||||
is_network_device = forms.BooleanField(
|
||||
required=False, label='Is a network device', widget=forms.CheckboxInput(attrs={'value': 'True'})
|
||||
)
|
||||
subdevice_role = forms.NullBooleanField(
|
||||
required=False, label='Subdevice role', widget=forms.Select(choices=(
|
||||
('', '---------'),
|
||||
(SUBDEVICE_ROLE_PARENT, 'Parent'),
|
||||
(SUBDEVICE_ROLE_CHILD, 'Child'),
|
||||
))
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@@ -524,7 +496,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['name', 'slug']
|
||||
fields = ['name', 'slug', 'rpc_client']
|
||||
|
||||
|
||||
#
|
||||
@@ -540,7 +512,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
|
||||
))
|
||||
position = forms.TypedChoiceField(required=False, empty_value=None,
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
|
||||
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}',
|
||||
disabled_indicator='device'))
|
||||
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
|
||||
widget=forms.Select(attrs={'filter-for': 'device_type'}))
|
||||
@@ -1422,16 +1394,9 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces which belong to the parent device.
|
||||
device = None
|
||||
if self.initial.get('device'):
|
||||
try:
|
||||
device = Device.objects.get(pk=self.initial.get('device'))
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
if device is not None:
|
||||
interface_ordering = device.device_type.interface_ordering
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
||||
device=device, form_factor=IFACE_FF_LAG
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device=self.initial['device'], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].choices = []
|
||||
@@ -1501,7 +1466,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
|
||||
super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Initialize interface A choices
|
||||
device_a_interfaces = Interface.objects.order_naturally().filter(device=device_a).exclude(
|
||||
device_a_interfaces = Interface.objects.filter(device=device_a).exclude(
|
||||
form_factor__in=VIRTUAL_IFACE_TYPES
|
||||
).select_related(
|
||||
'circuit_termination', 'connected_as_a', 'connected_as_b'
|
||||
@@ -1678,25 +1643,52 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
|
||||
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
|
||||
device = forms.CharField(required=False, label='Device name')
|
||||
|
||||
|
||||
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
|
||||
device = forms.CharField(required=False, label='Device name')
|
||||
|
||||
|
||||
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug')
|
||||
device = forms.CharField(required=False, label='Device name')
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class ModuleForm(BootstrapMixin, forms.ModelForm):
|
||||
class IPAddressForm(BootstrapMixin, CustomFieldForm):
|
||||
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description']
|
||||
|
||||
def __init__(self, device, *args, **kwargs):
|
||||
|
||||
super(IPAddressForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
interfaces = device.interfaces.all()
|
||||
self.fields['interface'].queryset = interfaces
|
||||
self.fields['interface'].required = True
|
||||
|
||||
# If this device has only one interface, select it by default.
|
||||
if len(interfaces) == 1:
|
||||
self.fields['interface'].initial = interfaces[0]
|
||||
|
||||
# If this device does not have any IP addresses assigned, default to setting the first IP as its primary.
|
||||
if not IPAddress.objects.filter(interface__device=device).count():
|
||||
self.fields['set_as_primary'].initial = True
|
||||
|
||||
|
||||
#
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
class InventoryItemForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = ['name', 'manufacturer', 'part_id', 'serial']
|
||||
|
||||
21
netbox/dcim/migrations/0033_rackreservation_rack_editable.py
Normal file
21
netbox/dcim/migrations/0033_rackreservation_rack_editable.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.6 on 2017-03-17 18:39
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0032_device_increase_name_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='rackreservation',
|
||||
name='rack',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.6 on 2017-03-21 14:55
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0033_rackreservation_rack_editable'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='Module',
|
||||
new_name='InventoryItem',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='device',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='dcim.Device'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.InventoryItem'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='manufacturer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.Manufacturer'),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections import OrderedDict
|
||||
from itertools import count, groupby
|
||||
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
@@ -534,7 +533,7 @@ class RackReservation(models.Model):
|
||||
"""
|
||||
One or more reserved units within a Rack.
|
||||
"""
|
||||
rack = models.ForeignKey('Rack', related_name='reservations', editable=False, on_delete=models.CASCADE)
|
||||
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
|
||||
units = ArrayField(models.PositiveSmallIntegerField())
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT)
|
||||
@@ -572,15 +571,6 @@ class RackReservation(models.Model):
|
||||
)
|
||||
})
|
||||
|
||||
@property
|
||||
def unit_list(self):
|
||||
"""
|
||||
Express the assigned units as a string of summarized ranges. For example:
|
||||
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
|
||||
"""
|
||||
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
|
||||
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
|
||||
|
||||
|
||||
#
|
||||
# Device Types
|
||||
@@ -791,9 +781,9 @@ class InterfaceManager(models.Manager):
|
||||
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
|
||||
|
||||
To order interfaces naturally, the `name` field is split into five distinct components: leading text (name),
|
||||
slot, subslot, position, channel, and virtual circuit:
|
||||
slot, subslot, position, and channel:
|
||||
|
||||
{name}{slot}/{subslot}/{position}:{channel}.{vc}
|
||||
{name}{slot}/{subslot}/{position}:{channel}
|
||||
|
||||
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
|
||||
be parsed as follows:
|
||||
@@ -803,23 +793,21 @@ class InterfaceManager(models.Manager):
|
||||
subslot = 0
|
||||
position = 1
|
||||
channel = None
|
||||
vc = 0
|
||||
|
||||
The chosen sorting method will determine which fields are ordered first in the query.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
sql_col = '{}.name'.format(queryset.model._meta.db_table)
|
||||
ordering = {
|
||||
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_name'),
|
||||
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel', '_vc'),
|
||||
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'),
|
||||
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'),
|
||||
}[method]
|
||||
return queryset.extra(select={
|
||||
'_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
|
||||
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col),
|
||||
'_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
|
||||
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
|
||||
'_channel': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
|
||||
}).order_by(*ordering)
|
||||
|
||||
|
||||
@@ -1409,19 +1397,19 @@ class DeviceBay(models.Model):
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Module(models.Model):
|
||||
class InventoryItem(models.Model):
|
||||
"""
|
||||
A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only
|
||||
for inventory purposes.
|
||||
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
|
||||
InventoryItems are used only for inventory purposes.
|
||||
"""
|
||||
device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
|
||||
parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE)
|
||||
device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE)
|
||||
parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=50, verbose_name='Name')
|
||||
manufacturer = models.ForeignKey('Manufacturer', related_name='modules', blank=True, null=True,
|
||||
manufacturer = models.ForeignKey('Manufacturer', related_name='inventory_items', blank=True, null=True,
|
||||
on_delete=models.PROTECT)
|
||||
part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
|
||||
serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
|
||||
|
||||
@@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
|
||||
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, RackReservation, Region, Site,
|
||||
RackGroup, Region, Site,
|
||||
)
|
||||
|
||||
|
||||
@@ -64,12 +64,6 @@ RACK_ROLE = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACKRESERVATION_ACTIONS = """
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_ACTIONS = """
|
||||
{% if perms.dcim.change_devicerole %}
|
||||
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
@@ -106,10 +100,6 @@ DEVICE_PRIMARY_IP = """
|
||||
{{ record.primary_ip4.address.ip|default:"" }}
|
||||
"""
|
||||
|
||||
SUBDEVICE_ROLE_TEMPLATE = """
|
||||
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
UTILIZATION_GRAPH = """
|
||||
{% load helpers %}
|
||||
{% utilization_graph value %}
|
||||
@@ -232,23 +222,6 @@ class RackImportTable(BaseTable):
|
||||
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
|
||||
|
||||
|
||||
#
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackReservation
|
||||
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
@@ -276,18 +249,11 @@ class DeviceTypeTable(BaseTable):
|
||||
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
|
||||
part_number = tables.Column(verbose_name='Part Number')
|
||||
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
|
||||
is_console_server = tables.BooleanColumn(verbose_name='CS')
|
||||
is_pdu = tables.BooleanColumn(verbose_name='PDU')
|
||||
is_network_device = tables.BooleanColumn(verbose_name='Net')
|
||||
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
|
||||
instance_count = tables.Column(verbose_name='Instances')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceType
|
||||
fields = (
|
||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
|
||||
'is_network_device', 'subdevice_role', 'instance_count'
|
||||
)
|
||||
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count')
|
||||
|
||||
|
||||
#
|
||||
@@ -381,12 +347,13 @@ class PlatformTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
device_count = tables.Column(verbose_name='Devices')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
rpc_client = tables.Column(accessor='get_rpc_client_display', orderable=False, verbose_name='RPC Client')
|
||||
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Platform
|
||||
fields = ('pk', 'name', 'device_count', 'slug', 'actions')
|
||||
fields = ('pk', 'name', 'device_count', 'slug', 'rpc_client', 'actions')
|
||||
|
||||
|
||||
#
|
||||
|
||||
2072
netbox/dcim/tests/test_api.py
Normal file
2072
netbox/dcim/tests/test_api.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,676 +0,0 @@
|
||||
import json
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SiteTest(APITestCase):
|
||||
|
||||
fixtures = [
|
||||
'dcim',
|
||||
'ipam',
|
||||
'extras',
|
||||
]
|
||||
|
||||
standard_fields = [
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'region',
|
||||
'tenant',
|
||||
'facility',
|
||||
'asn',
|
||||
'physical_address',
|
||||
'shipping_address',
|
||||
'contact_name',
|
||||
'contact_phone',
|
||||
'contact_email',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'count_prefixes',
|
||||
'count_vlans',
|
||||
'count_racks',
|
||||
'count_devices',
|
||||
'count_circuits'
|
||||
]
|
||||
|
||||
nested_fields = [
|
||||
'id',
|
||||
'name',
|
||||
'slug'
|
||||
]
|
||||
|
||||
rack_fields = [
|
||||
'id',
|
||||
'name',
|
||||
'facility_id',
|
||||
'display_name',
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'desc_units',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
graph_fields = [
|
||||
'name',
|
||||
'embed_url',
|
||||
'embed_link',
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in json.loads(response.content.decode('utf-8')):
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.rack_fields),
|
||||
)
|
||||
# Check Nested Serializer.
|
||||
self.assertEqual(
|
||||
sorted(i.get('site').keys()),
|
||||
sorted(self.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in json.loads(response.content.decode('utf-8')):
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.graph_fields),
|
||||
)
|
||||
|
||||
|
||||
class RackTest(APITestCase):
|
||||
fixtures = [
|
||||
'dcim',
|
||||
'ipam'
|
||||
]
|
||||
|
||||
nested_fields = [
|
||||
'id',
|
||||
'name',
|
||||
'facility_id',
|
||||
'display_name'
|
||||
]
|
||||
|
||||
standard_fields = [
|
||||
'id',
|
||||
'name',
|
||||
'facility_id',
|
||||
'display_name',
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'desc_units',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
detail_fields = [
|
||||
'id',
|
||||
'name',
|
||||
'facility_id',
|
||||
'display_name',
|
||||
'site',
|
||||
'group',
|
||||
'tenant',
|
||||
'role',
|
||||
'type',
|
||||
'width',
|
||||
'u_height',
|
||||
'desc_units',
|
||||
'reservations',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'front_units',
|
||||
'rear_units'
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(i.get('site').keys()),
|
||||
sorted(SiteTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.detail_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(content.get('site').keys()),
|
||||
sorted(SiteTest.nested_fields),
|
||||
)
|
||||
|
||||
|
||||
class ManufacturersTest(APITestCase):
|
||||
|
||||
fixtures = [
|
||||
'dcim',
|
||||
'ipam'
|
||||
]
|
||||
|
||||
standard_fields = [
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
]
|
||||
|
||||
nested_fields = standard_fields
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeTest(APITestCase):
|
||||
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = [
|
||||
'id',
|
||||
'manufacturer',
|
||||
'model',
|
||||
'slug',
|
||||
'part_number',
|
||||
'u_height',
|
||||
'is_full_depth',
|
||||
'interface_ordering',
|
||||
'is_console_server',
|
||||
'is_pdu',
|
||||
'is_network_device',
|
||||
'subdevice_role',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
'instance_count',
|
||||
]
|
||||
|
||||
nested_fields = [
|
||||
'id',
|
||||
'manufacturer',
|
||||
'model',
|
||||
'slug'
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
|
||||
# TODO: details returns list view.
|
||||
# response = self.client.get(endpoint)
|
||||
# content = json.loads(response.content.decode('utf-8'))
|
||||
# self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# self.assertEqual(
|
||||
# sorted(content.keys()),
|
||||
# sorted(self.standard_fields),
|
||||
# )
|
||||
# self.assertEqual(
|
||||
# sorted(content.get('manufacturer').keys()),
|
||||
# sorted(ManufacturersTest.nested_fields),
|
||||
# )
|
||||
pass
|
||||
|
||||
|
||||
class DeviceRolesTest(APITestCase):
|
||||
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = ['id', 'name', 'slug', 'color']
|
||||
|
||||
nested_fields = ['id', 'name', 'slug']
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
|
||||
class PlatformsTest(APITestCase):
|
||||
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = ['id', 'name', 'slug', 'rpc_client']
|
||||
|
||||
nested_fields = ['id', 'name', 'slug']
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
|
||||
class DeviceTest(APITestCase):
|
||||
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = [
|
||||
'id',
|
||||
'name',
|
||||
'display_name',
|
||||
'device_type',
|
||||
'device_role',
|
||||
'tenant',
|
||||
'platform',
|
||||
'serial',
|
||||
'asset_tag',
|
||||
'site',
|
||||
'rack',
|
||||
'position',
|
||||
'face',
|
||||
'parent_device',
|
||||
'status',
|
||||
'primary_ip',
|
||||
'primary_ip4',
|
||||
'primary_ip6',
|
||||
'comments',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
nested_fields = ['id', 'name', 'display_name']
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for device in content:
|
||||
self.assertEqual(
|
||||
sorted(device.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(device.get('device_type')),
|
||||
sorted(DeviceTypeTest.nested_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(device.get('device_role')),
|
||||
sorted(DeviceRolesTest.nested_fields),
|
||||
)
|
||||
if device.get('platform'):
|
||||
self.assertEqual(
|
||||
sorted(device.get('platform')),
|
||||
sorted(PlatformsTest.nested_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(device.get('rack')),
|
||||
sorted(RackTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_list_flat(self, endpoint='/{}api/dcim/devices/?format=json_flat'.format(settings.BASE_PATH)):
|
||||
|
||||
flat_fields = [
|
||||
'asset_tag',
|
||||
'comments',
|
||||
'device_role_id',
|
||||
'device_role_name',
|
||||
'device_role_slug',
|
||||
'device_type_id',
|
||||
'device_type_manufacturer_id',
|
||||
'device_type_manufacturer_name',
|
||||
'device_type_manufacturer_slug',
|
||||
'device_type_model',
|
||||
'device_type_slug',
|
||||
'display_name',
|
||||
'face',
|
||||
'id',
|
||||
'name',
|
||||
'parent_device',
|
||||
'platform_id',
|
||||
'platform_name',
|
||||
'platform_slug',
|
||||
'position',
|
||||
'primary_ip_address',
|
||||
'primary_ip_family',
|
||||
'primary_ip_id',
|
||||
'primary_ip4_address',
|
||||
'primary_ip4_family',
|
||||
'primary_ip4_id',
|
||||
'primary_ip6',
|
||||
'site_id',
|
||||
'site_name',
|
||||
'site_slug',
|
||||
'rack_display_name',
|
||||
'rack_facility_id',
|
||||
'rack_id',
|
||||
'rack_name',
|
||||
'serial',
|
||||
'status',
|
||||
'tenant',
|
||||
]
|
||||
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
device = content[0]
|
||||
self.assertEqual(
|
||||
sorted(device.keys()),
|
||||
sorted(flat_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
|
||||
|
||||
class ConsoleServerPortsTest(APITestCase):
|
||||
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = ['id', 'device', 'name', 'connected_console']
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for console_port in content:
|
||||
self.assertEqual(
|
||||
sorted(console_port.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(console_port.get('device')),
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortsTest(APITestCase):
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for console_port in content:
|
||||
self.assertEqual(
|
||||
sorted(console_port.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(console_port.get('device')),
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(console_port.get('cs_port')),
|
||||
sorted(ConsoleServerPortsTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(content.get('device')),
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
|
||||
class PowerPortsTest(APITestCase):
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(i.get('device')),
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(content.get('device')),
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletsTest(APITestCase):
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = ['id', 'device', 'name', 'connected_port']
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(i.get('device')),
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
|
||||
class InterfaceTest(APITestCase):
|
||||
fixtures = ['dcim', 'ipam', 'extras']
|
||||
|
||||
standard_fields = [
|
||||
'id',
|
||||
'device',
|
||||
'name',
|
||||
'form_factor',
|
||||
'lag',
|
||||
'mac_address',
|
||||
'mgmt_only',
|
||||
'description',
|
||||
'is_connected'
|
||||
]
|
||||
|
||||
nested_fields = ['id', 'device', 'name']
|
||||
|
||||
detail_fields = [
|
||||
'id',
|
||||
'device',
|
||||
'name',
|
||||
'form_factor',
|
||||
'lag',
|
||||
'mac_address',
|
||||
'mgmt_only',
|
||||
'description',
|
||||
'is_connected',
|
||||
'connected_interface'
|
||||
]
|
||||
|
||||
connection_fields = [
|
||||
'id',
|
||||
'interface_a',
|
||||
'interface_b',
|
||||
'connection_status',
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(i.get('device')),
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.detail_fields),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(content.get('device')),
|
||||
sorted(DeviceTest.nested_fields),
|
||||
)
|
||||
|
||||
def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for i in content:
|
||||
self.assertEqual(
|
||||
sorted(i.keys()),
|
||||
sorted(SiteTest.graph_fields),
|
||||
)
|
||||
|
||||
def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
|
||||
.format(settings.BASE_PATH)):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.connection_fields),
|
||||
)
|
||||
|
||||
|
||||
class RelatedConnectionsTest(APITestCase):
|
||||
|
||||
fixtures = ['dcim', 'ipam']
|
||||
|
||||
standard_fields = [
|
||||
'device',
|
||||
'console-ports',
|
||||
'power-ports',
|
||||
'interfaces',
|
||||
]
|
||||
|
||||
def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
|
||||
.format(settings.BASE_PATH))):
|
||||
response = self.client.get(endpoint)
|
||||
content = json.loads(response.content.decode('utf-8'))
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
sorted(content.keys()),
|
||||
sorted(self.standard_fields),
|
||||
)
|
||||
@@ -36,8 +36,6 @@ urlpatterns = [
|
||||
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
|
||||
|
||||
# Rack reservations
|
||||
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
|
||||
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
|
||||
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
|
||||
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
|
||||
|
||||
@@ -116,6 +114,7 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
|
||||
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
|
||||
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
|
||||
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
|
||||
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
|
||||
|
||||
@@ -174,6 +173,11 @@ urlpatterns = [
|
||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
|
||||
|
||||
# Inventory items
|
||||
url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
|
||||
url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
|
||||
url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
|
||||
|
||||
# Console/power/interface connections
|
||||
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
|
||||
url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
|
||||
@@ -182,9 +186,4 @@ urlpatterns = [
|
||||
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
|
||||
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
|
||||
|
||||
# Modules
|
||||
url(r'^devices/(?P<device>\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'),
|
||||
url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
|
||||
url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
|
||||
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.http import urlencode
|
||||
from django.views.generic import View
|
||||
|
||||
from ipam.models import Prefix, Service, VLAN
|
||||
from ipam.models import Prefix, IPAddress, Service, VLAN
|
||||
from circuits.models import Circuit
|
||||
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from utilities.forms import ConfirmationForm
|
||||
@@ -25,7 +25,7 @@ from . import filters, forms, tables
|
||||
from .models import (
|
||||
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
|
||||
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
|
||||
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site,
|
||||
)
|
||||
|
||||
@@ -124,13 +124,13 @@ class ComponentCreateView(View):
|
||||
|
||||
class ComponentEditView(ObjectEditView):
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return obj.device.get_absolute_url()
|
||||
|
||||
|
||||
class ComponentDeleteView(ObjectDeleteView):
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return obj.device.get_absolute_url()
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Region
|
||||
form_class = forms.RegionForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('dcim:region_list')
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = RackGroup
|
||||
form_class = forms.RackGroupForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('dcim:rackgroup_list')
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = RackRole
|
||||
form_class = forms.RackRoleForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('dcim:rackrole_list')
|
||||
|
||||
|
||||
@@ -360,14 +360,6 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationListView(ObjectListView):
|
||||
queryset = RackReservation.objects.all()
|
||||
filter = filters.RackReservationFilter
|
||||
filter_form = forms.RackReservationFilterForm
|
||||
table = tables.RackReservationTable
|
||||
template_name = 'dcim/rackreservation_list.html'
|
||||
|
||||
|
||||
class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_rackreservation'
|
||||
model = RackReservation
|
||||
@@ -379,7 +371,7 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
obj.user = request.user
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return obj.rack.get_absolute_url()
|
||||
|
||||
|
||||
@@ -387,16 +379,10 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'dcim.delete_rackreservation'
|
||||
model = RackReservation
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return obj.rack.get_absolute_url()
|
||||
|
||||
|
||||
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_rackreservation'
|
||||
cls = RackReservation
|
||||
default_return_url = 'dcim:rackreservation_list'
|
||||
|
||||
|
||||
#
|
||||
# Manufacturers
|
||||
#
|
||||
@@ -412,7 +398,7 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Manufacturer
|
||||
form_class = forms.ManufacturerForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('dcim:manufacturer_list')
|
||||
|
||||
|
||||
@@ -632,7 +618,7 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = DeviceRole
|
||||
form_class = forms.DeviceRoleForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('dcim:devicerole_list')
|
||||
|
||||
|
||||
@@ -657,7 +643,7 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Platform
|
||||
form_class = forms.PlatformForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('dcim:platform_list')
|
||||
|
||||
|
||||
@@ -700,15 +686,19 @@ def device(request, pk):
|
||||
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
|
||||
.filter(device=device, mgmt_only=False)\
|
||||
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
|
||||
'circuit_termination__circuit').prefetch_related('ip_addresses')
|
||||
'circuit_termination__circuit')
|
||||
mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
|
||||
.filter(device=device, mgmt_only=True)\
|
||||
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
|
||||
'circuit_termination__circuit').prefetch_related('ip_addresses')
|
||||
'circuit_termination__circuit')
|
||||
device_bays = natsorted(
|
||||
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
|
||||
key=attrgetter('name')
|
||||
)
|
||||
|
||||
# Gather relevant device objects
|
||||
ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
|
||||
.order_by('address')
|
||||
services = Service.objects.filter(device=device)
|
||||
secrets = device.secrets.all()
|
||||
|
||||
@@ -739,6 +729,7 @@ def device(request, pk):
|
||||
'interfaces': interfaces,
|
||||
'mgmt_interfaces': mgmt_interfaces,
|
||||
'device_bays': device_bays,
|
||||
'ip_addresses': ip_addresses,
|
||||
'services': services,
|
||||
'secrets': secrets,
|
||||
'related_devices': related_devices,
|
||||
@@ -750,6 +741,7 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_device'
|
||||
model = Device
|
||||
form_class = forms.DeviceForm
|
||||
fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
|
||||
template_name = 'dcim/device_edit.html'
|
||||
default_return_url = 'dcim:device_list'
|
||||
|
||||
@@ -807,12 +799,12 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
def device_inventory(request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
modules = Module.objects.filter(device=device, parent=None).select_related('manufacturer')\
|
||||
.prefetch_related('submodules')
|
||||
inventory_items = InventoryItem.objects.filter(device=device, parent=None).select_related('manufacturer')\
|
||||
.prefetch_related('child_items')
|
||||
|
||||
return render(request, 'dcim/device_inventory.html', {
|
||||
'device': device,
|
||||
'modules': modules,
|
||||
'inventory_items': inventory_items,
|
||||
})
|
||||
|
||||
|
||||
@@ -1458,10 +1450,9 @@ def interfaceconnection_add(request, pk):
|
||||
))
|
||||
if '_addanother' in request.POST:
|
||||
base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
|
||||
device_b = interfaceconnection.interface_b.device
|
||||
params = urlencode({
|
||||
'rack_b': device_b.rack.pk if device_b.rack else '',
|
||||
'device_b': device_b.pk,
|
||||
'rack_b': interfaceconnection.interface_b.device.rack.pk,
|
||||
'device_b': interfaceconnection.interface_b.device.pk,
|
||||
})
|
||||
return HttpResponseRedirect('{}?{}'.format(base_url, params))
|
||||
else:
|
||||
@@ -1562,13 +1553,54 @@ class InterfaceConnectionsListView(ObjectListView):
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_module'
|
||||
model = Module
|
||||
form_class = forms.ModuleForm
|
||||
@permission_required(['dcim.change_device', 'ipam.add_ipaddress'])
|
||||
def ipaddress_assign(request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = forms.IPAddressForm(device, request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
ipaddress = form.save(commit=False)
|
||||
ipaddress.interface = form.cleaned_data['interface']
|
||||
ipaddress.save()
|
||||
form.save_custom_fields()
|
||||
messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
|
||||
|
||||
if form.cleaned_data['set_as_primary']:
|
||||
if ipaddress.family == 4:
|
||||
device.primary_ip4 = ipaddress
|
||||
elif ipaddress.family == 6:
|
||||
device.primary_ip6 = ipaddress
|
||||
device.save()
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('dcim:ipaddress_assign', pk=device.pk)
|
||||
else:
|
||||
return redirect('dcim:device', pk=device.pk)
|
||||
|
||||
else:
|
||||
form = forms.IPAddressForm(device)
|
||||
|
||||
return render(request, 'dcim/ipaddress_assign.html', {
|
||||
'device': device,
|
||||
'form': form,
|
||||
'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
permission_required = 'dcim.change_inventoryitem'
|
||||
model = InventoryItem
|
||||
form_class = forms.InventoryItemForm
|
||||
|
||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||
if 'device' in url_kwargs:
|
||||
@@ -1576,6 +1608,6 @@ class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
return obj
|
||||
|
||||
|
||||
class ModuleDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_module'
|
||||
model = Module
|
||||
class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
permission_required = 'dcim.delete_inventoryitem'
|
||||
model = InventoryItem
|
||||
|
||||
48
netbox/extras/api/customfields.py
Normal file
48
netbox/extras/api/customfields.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class CustomFieldSerializer(serializers.BaseSerializer):
|
||||
"""
|
||||
Extends ModelSerializer to render any CustomFields and their values associated with an object.
|
||||
"""
|
||||
|
||||
def to_representation(self, manager):
|
||||
|
||||
# Initialize custom fields dictionary
|
||||
data = {f.name: None for f in self.parent._custom_fields}
|
||||
|
||||
# Assign CustomFieldValues from database
|
||||
for cfv in manager.all():
|
||||
if cfv.field.type == CF_TYPE_SELECT:
|
||||
data[cfv.field.name] = CustomFieldChoiceSerializer(cfv.value).data
|
||||
else:
|
||||
data[cfv.field.name] = cfv.value
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CustomFieldModelSerializer(serializers.ModelSerializer):
|
||||
custom_fields = CustomFieldSerializer(source='custom_field_values')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(CustomFieldModelSerializer, self).__init__(*args, **kwargs)
|
||||
|
||||
# Cache the list of custom fields for this model
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
self._custom_fields = CustomField.objects.filter(obj_type=content_type)
|
||||
|
||||
|
||||
class CustomFieldChoiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoice
|
||||
fields = ['id', 'value']
|
||||
@@ -1,88 +0,0 @@
|
||||
import json
|
||||
from rest_framework import renderers
|
||||
|
||||
|
||||
# IP address family designations
|
||||
AF = {
|
||||
4: 'A',
|
||||
6: 'AAAA',
|
||||
}
|
||||
|
||||
|
||||
class FormlessBrowsableAPIRenderer(renderers.BrowsableAPIRenderer):
|
||||
"""
|
||||
An instance of the browseable API with forms suppressed. Useful for POST endpoints that don't create objects.
|
||||
"""
|
||||
def show_form_for_method(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
|
||||
class BINDZoneRenderer(renderers.BaseRenderer):
|
||||
"""
|
||||
Generate a BIND zone file from a list of DNS records.
|
||||
Required fields: `name`, `primary_ip`
|
||||
"""
|
||||
media_type = 'text/plain'
|
||||
format = 'bind-zone'
|
||||
|
||||
def render(self, data, media_type=None, renderer_context=None):
|
||||
records = []
|
||||
for record in data:
|
||||
if record.get('name') and record.get('primary_ip'):
|
||||
try:
|
||||
records.append("{} IN {} {}".format(
|
||||
record['name'],
|
||||
AF[record['primary_ip']['family']],
|
||||
record['primary_ip']['address'].split('/')[0],
|
||||
))
|
||||
except KeyError:
|
||||
pass
|
||||
return '\n'.join(records)
|
||||
|
||||
|
||||
class FlatJSONRenderer(renderers.BaseRenderer):
|
||||
"""
|
||||
Flattens a nested JSON response.
|
||||
"""
|
||||
format = 'json_flat'
|
||||
media_type = 'application/json'
|
||||
|
||||
def render(self, data, media_type=None, renderer_context=None):
|
||||
|
||||
def flatten(entry):
|
||||
for key, val in entry.items():
|
||||
if isinstance(val, dict):
|
||||
for child_key, child_val in flatten(val):
|
||||
yield "{}_{}".format(key, child_key), child_val
|
||||
else:
|
||||
yield key, val
|
||||
|
||||
return json.dumps([dict(flatten(i)) for i in data])
|
||||
|
||||
|
||||
class FreeRADIUSClientsRenderer(renderers.BaseRenderer):
|
||||
"""
|
||||
Generate a FreeRADIUS clients.conf file from a list of Secrets.
|
||||
"""
|
||||
media_type = 'text/plain'
|
||||
format = 'freeradius'
|
||||
|
||||
CLIENT_TEMPLATE = """client {name} {{
|
||||
ipaddr = {ip}
|
||||
secret = {secret}
|
||||
}}"""
|
||||
|
||||
def render(self, data, media_type=None, renderer_context=None):
|
||||
clients = []
|
||||
try:
|
||||
for secret in data:
|
||||
if secret['device']['primary_ip'] and secret['plaintext']:
|
||||
client = self.CLIENT_TEMPLATE.format(
|
||||
name=secret['device']['name'],
|
||||
ip=secret['device']['primary_ip']['address'].split('/')[0],
|
||||
secret=secret['plaintext']
|
||||
)
|
||||
clients.append(client)
|
||||
except:
|
||||
pass
|
||||
return '\n'.join(clients)
|
||||
@@ -1,56 +1,84 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph
|
||||
from dcim.api.serializers import NestedSiteSerializer
|
||||
from extras.models import ACTION_CHOICES, Graph, GRAPH_TYPE_CHOICES, ExportTemplate, TopologyMap, UserAction
|
||||
from users.api.serializers import NestedUserSerializer
|
||||
from utilities.api import ChoiceFieldSerializer
|
||||
|
||||
|
||||
class CustomFieldSerializer(serializers.Serializer):
|
||||
"""
|
||||
Extends a ModelSerializer to render any CustomFields and their values associated with an object.
|
||||
"""
|
||||
custom_fields = serializers.SerializerMethodField()
|
||||
|
||||
def get_custom_fields(self, obj):
|
||||
|
||||
# Gather all CustomFields applicable to this object
|
||||
fields = {cf.name: None for cf in self.context['view'].custom_fields}
|
||||
|
||||
# Attach any defined CustomFieldValues to their respective CustomFields
|
||||
for cfv in obj.custom_field_values.all():
|
||||
|
||||
# Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view
|
||||
# context.
|
||||
if cfv.field.type == CF_TYPE_SELECT and hasattr(self, 'custom_field_choices'):
|
||||
cfc = {
|
||||
'id': int(cfv.serialized_value),
|
||||
'value': self.context['view'].custom_field_choices[int(cfv.serialized_value)]
|
||||
}
|
||||
fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data
|
||||
# Fall back to hitting the database in case we're in a view that doesn't inherit CustomFieldModelAPIView.
|
||||
elif cfv.field.type == CF_TYPE_SELECT:
|
||||
fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data
|
||||
else:
|
||||
fields[cfv.field.name] = cfv.value
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
class CustomFieldChoiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoice
|
||||
fields = ['id', 'value']
|
||||
|
||||
#
|
||||
# Graphs
|
||||
#
|
||||
|
||||
class GraphSerializer(serializers.ModelSerializer):
|
||||
embed_url = serializers.SerializerMethodField()
|
||||
embed_link = serializers.SerializerMethodField()
|
||||
type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = Graph
|
||||
fields = ['name', 'embed_url', 'embed_link']
|
||||
fields = ['id', 'type', 'weight', 'name', 'source', 'link']
|
||||
|
||||
|
||||
class WritableGraphSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Graph
|
||||
fields = ['id', 'type', 'weight', 'name', 'source', 'link']
|
||||
|
||||
|
||||
class RenderedGraphSerializer(serializers.ModelSerializer):
|
||||
embed_url = serializers.SerializerMethodField()
|
||||
embed_link = serializers.SerializerMethodField()
|
||||
type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = Graph
|
||||
fields = ['id', 'type', 'weight', 'name', 'embed_url', 'embed_link']
|
||||
|
||||
def get_embed_url(self, obj):
|
||||
return obj.embed_url(self.context['graphed_object'])
|
||||
|
||||
def get_embed_link(self, obj):
|
||||
return obj.embed_link(self.context['graphed_object'])
|
||||
|
||||
|
||||
#
|
||||
# Export templates
|
||||
#
|
||||
|
||||
class ExportTemplateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
|
||||
|
||||
|
||||
#
|
||||
# Topology maps
|
||||
#
|
||||
|
||||
class TopologyMapSerializer(serializers.ModelSerializer):
|
||||
site = NestedSiteSerializer()
|
||||
|
||||
class Meta:
|
||||
model = TopologyMap
|
||||
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
|
||||
|
||||
|
||||
class WritableTopologyMapSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = TopologyMap
|
||||
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
|
||||
|
||||
|
||||
#
|
||||
# User actions
|
||||
#
|
||||
|
||||
class UserActionSerializer(serializers.ModelSerializer):
|
||||
user = NestedUserSerializer()
|
||||
action = ChoiceFieldSerializer(choices=ACTION_CHOICES)
|
||||
|
||||
class Meta:
|
||||
model = UserAction
|
||||
fields = ['id', 'time', 'user', 'action', 'message']
|
||||
|
||||
29
netbox/extras/api/urls.py
Normal file
29
netbox/extras/api/urls.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
|
||||
class ExtrasRootView(routers.APIRootView):
|
||||
"""
|
||||
Extras API root view
|
||||
"""
|
||||
def get_view_name(self):
|
||||
return 'Extras'
|
||||
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.APIRootView = ExtrasRootView
|
||||
|
||||
# Graphs
|
||||
router.register(r'graphs', views.GraphViewSet)
|
||||
|
||||
# Export templates
|
||||
router.register(r'export-templates', views.ExportTemplateViewSet)
|
||||
|
||||
# Topology maps
|
||||
router.register(r'topology-maps', views.TopologyMapViewSet)
|
||||
|
||||
# Recent activity
|
||||
router.register(r'recent-activity', views.RecentActivityViewSet)
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -1,115 +1,90 @@
|
||||
import graphviz
|
||||
from rest_framework import generics
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from circuits.models import Provider
|
||||
from dcim.models import Site, Device, Interface, InterfaceConnection
|
||||
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE
|
||||
|
||||
from .serializers import GraphSerializer
|
||||
from extras import filters
|
||||
from extras.models import ExportTemplate, Graph, TopologyMap, UserAction
|
||||
from utilities.api import WritableSerializerMixin
|
||||
from . import serializers
|
||||
|
||||
|
||||
class CustomFieldModelAPIView(object):
|
||||
class CustomFieldModelViewSet(ModelViewSet):
|
||||
"""
|
||||
Include the applicable set of CustomField in the view context.
|
||||
Include the applicable set of CustomFields in the ModelViewSet context.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(CustomFieldModelAPIView, self).__init__()
|
||||
self.content_type = ContentType.objects.get_for_model(self.queryset.model)
|
||||
self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
|
||||
def get_serializer_context(self):
|
||||
|
||||
# Gather all custom fields for the model
|
||||
content_type = ContentType.objects.get_for_model(self.queryset.model)
|
||||
custom_fields = content_type.custom_fields.prefetch_related('choices')
|
||||
|
||||
# Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object.
|
||||
custom_field_choices = {}
|
||||
for field in self.custom_fields:
|
||||
for field in custom_fields:
|
||||
for cfc in field.choices.all():
|
||||
custom_field_choices[cfc.id] = cfc.value
|
||||
self.custom_field_choices = custom_field_choices
|
||||
custom_field_choices = custom_field_choices
|
||||
|
||||
|
||||
class GraphListView(generics.ListAPIView):
|
||||
"""
|
||||
Returns a list of relevant graphs
|
||||
"""
|
||||
serializer_class = GraphSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
cls = {
|
||||
GRAPH_TYPE_INTERFACE: Interface,
|
||||
GRAPH_TYPE_PROVIDER: Provider,
|
||||
GRAPH_TYPE_SITE: Site,
|
||||
}
|
||||
context = super(GraphListView, self).get_serializer_context()
|
||||
context.update({'graphed_object': get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk'])})
|
||||
context = super(CustomFieldModelViewSet, self).get_serializer_context()
|
||||
context.update({
|
||||
'custom_fields': custom_fields,
|
||||
'custom_field_choices': custom_field_choices,
|
||||
})
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
graph_type = self.kwargs.get('type', None)
|
||||
if not graph_type:
|
||||
raise Http404()
|
||||
queryset = Graph.objects.filter(type=graph_type)
|
||||
return queryset
|
||||
# Prefetch custom field values
|
||||
return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field')
|
||||
|
||||
|
||||
class TopologyMapView(APIView):
|
||||
"""
|
||||
Generate a topology diagram
|
||||
"""
|
||||
class GraphViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = Graph.objects.all()
|
||||
serializer_class = serializers.GraphSerializer
|
||||
write_serializer_class = serializers.WritableGraphSerializer
|
||||
filter_class = filters.GraphFilter
|
||||
|
||||
def get(self, request, slug):
|
||||
|
||||
tmap = get_object_or_404(TopologyMap, slug=slug)
|
||||
class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
serializer_class = serializers.ExportTemplateSerializer
|
||||
# write_serializer_class = serializers.WritableExportTemplateSerializer
|
||||
filter_class = filters.ExportTemplateFilter
|
||||
|
||||
# Construct the graph
|
||||
graph = graphviz.Graph()
|
||||
graph.graph_attr['ranksep'] = '1'
|
||||
for i, device_set in enumerate(tmap.device_sets):
|
||||
|
||||
subgraph = graphviz.Graph(name='sg{}'.format(i))
|
||||
subgraph.graph_attr['rank'] = 'same'
|
||||
class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = TopologyMap.objects.select_related('site')
|
||||
serializer_class = serializers.TopologyMapSerializer
|
||||
write_serializer_class = serializers.WritableTopologyMapSerializer
|
||||
filter_class = filters.TopologyMapFilter
|
||||
|
||||
# Add a pseudonode for each device_set to enforce hierarchical layout
|
||||
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
|
||||
if i:
|
||||
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
|
||||
@detail_route()
|
||||
def render(self, request, pk):
|
||||
|
||||
# Add each device to the graph
|
||||
devices = []
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
devices += Device.objects.filter(name__regex=query)
|
||||
for d in devices:
|
||||
subgraph.node(d.name)
|
||||
tmap = get_object_or_404(TopologyMap, pk=pk)
|
||||
img_format = 'png'
|
||||
|
||||
# Add an invisible connection to each successive device in a set to enforce horizontal order
|
||||
for j in range(0, len(devices) - 1):
|
||||
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
|
||||
|
||||
graph.subgraph(subgraph)
|
||||
|
||||
# Compile list of all devices
|
||||
device_superset = Q()
|
||||
for device_set in tmap.device_sets:
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
device_superset = device_superset | Q(name__regex=query)
|
||||
|
||||
# Add all connections to the graph
|
||||
devices = Device.objects.filter(*(device_superset,))
|
||||
connections = InterfaceConnection.objects.filter(interface_a__device__in=devices,
|
||||
interface_b__device__in=devices)
|
||||
for c in connections:
|
||||
graph.edge(c.interface_a.device.name, c.interface_b.device.name)
|
||||
|
||||
# Get the image data and return
|
||||
try:
|
||||
topo_data = graph.pipe(format='png')
|
||||
data = tmap.render(img_format=img_format)
|
||||
except:
|
||||
return HttpResponse("There was an error generating the requested graph. Ensure that the GraphViz "
|
||||
"executables have been installed correctly.")
|
||||
response = HttpResponse(topo_data, content_type='image/png')
|
||||
return HttpResponse(
|
||||
"There was an error generating the requested graph. Ensure that the GraphViz executables have been "
|
||||
"installed correctly."
|
||||
)
|
||||
|
||||
response = HttpResponse(data, content_type='image/{}'.format(img_format))
|
||||
response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, img_format)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RecentActivityViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
List all UserActions to provide a log of recent activity.
|
||||
"""
|
||||
queryset = UserAction.objects.all()
|
||||
serializer_class = serializers.UserActionSerializer
|
||||
filter_class = filters.UserActionFilter
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import django_filters
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from .models import CF_TYPE_SELECT, CustomField
|
||||
from dcim.models import Site
|
||||
from .models import CF_TYPE_SELECT, CustomField, Graph, ExportTemplate, TopologyMap, UserAction
|
||||
|
||||
|
||||
class CustomFieldFilter(django_filters.Filter):
|
||||
@@ -44,3 +46,47 @@ class CustomFieldFilterSet(django_filters.FilterSet):
|
||||
custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
|
||||
for cf in custom_fields:
|
||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
|
||||
|
||||
|
||||
class GraphFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Graph
|
||||
fields = ['type', 'name']
|
||||
|
||||
|
||||
class ExportTemplateFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = ['content_type', 'name']
|
||||
|
||||
|
||||
class TopologyMapFilter(django_filters.FilterSet):
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TopologyMap
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class UserActionFilter(django_filters.FilterSet):
|
||||
username = django_filters.ModelMultipleChoiceFilter(
|
||||
name='user__username',
|
||||
queryset=User.objects.all(),
|
||||
to_field_name='username',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserAction
|
||||
fields = ['user']
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from dcim.models import Device, Module, Site
|
||||
from dcim.models import Device, InventoryItem, Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -25,12 +25,12 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
def create_modules(modules, parent=None):
|
||||
for module in modules:
|
||||
m = Module(device=device, parent=parent, name=module['name'], part_id=module['part_id'],
|
||||
serial=module['serial'], discovered=True)
|
||||
m.save()
|
||||
create_modules(module.get('modules', []), parent=m)
|
||||
def create_inventory_items(inventory_items, parent=None):
|
||||
for item in inventory_items:
|
||||
i = InventoryItem(device=device, parent=parent, name=item['name'], part_id=item['part_id'],
|
||||
serial=item['serial'], discovered=True)
|
||||
i.save()
|
||||
create_inventory_items(item.get('items', []), parent=i)
|
||||
|
||||
# Credentials
|
||||
if options['username']:
|
||||
@@ -107,9 +107,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write("")
|
||||
self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial']))
|
||||
self.stdout.write("\tDescription: {}".format(inventory['chassis']['description']))
|
||||
for module in inventory['modules']:
|
||||
self.stdout.write("\tModule: {} / {} ({})".format(module['name'], module['part_id'],
|
||||
module['serial']))
|
||||
for item in inventory['items']:
|
||||
self.stdout.write("\tItem: {} / {} ({})".format(item['name'], item['part_id'],
|
||||
item['serial']))
|
||||
else:
|
||||
self.stdout.write("{} ({})".format(inventory['chassis']['description'], inventory['chassis']['serial']))
|
||||
|
||||
@@ -119,7 +119,7 @@ class Command(BaseCommand):
|
||||
if device.serial != inventory['chassis']['serial']:
|
||||
device.serial = inventory['chassis']['serial']
|
||||
device.save()
|
||||
Module.objects.filter(device=device, discovered=True).delete()
|
||||
create_modules(inventory.get('modules', []))
|
||||
InventoryItem.objects.filter(device=device, discovered=True).delete()
|
||||
create_inventory_items(inventory.get('items', []))
|
||||
|
||||
self.stdout.write("Finished!")
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2017-04-04 19:45
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0004_topologymap_change_comma_to_semicolon'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='useraction',
|
||||
name='action',
|
||||
field=models.PositiveSmallIntegerField(choices=[(1, b'created'), (7, b'bulk created'), (2, b'imported'), (3, b'modified'), (4, b'bulk edited'), (5, b'deleted'), (6, b'bulk deleted')]),
|
||||
),
|
||||
]
|
||||
@@ -1,11 +1,13 @@
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
import graphviz
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
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 django.db.models import Q
|
||||
from django.http import HttpResponse
|
||||
from django.template import Template, Context
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
@@ -56,18 +58,20 @@ ACTION_EDIT = 3
|
||||
ACTION_BULK_EDIT = 4
|
||||
ACTION_DELETE = 5
|
||||
ACTION_BULK_DELETE = 6
|
||||
ACTION_BULK_CREATE = 7
|
||||
ACTION_CHOICES = (
|
||||
(ACTION_CREATE, 'created'),
|
||||
(ACTION_BULK_CREATE, 'bulk created'),
|
||||
(ACTION_IMPORT, 'imported'),
|
||||
(ACTION_EDIT, 'modified'),
|
||||
(ACTION_BULK_EDIT, 'bulk edited'),
|
||||
(ACTION_DELETE, 'deleted'),
|
||||
(ACTION_BULK_DELETE, 'bulk deleted'),
|
||||
(ACTION_BULK_DELETE, 'bulk deleted')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
class CustomFieldModel(object):
|
||||
|
||||
def cf(self):
|
||||
@@ -213,6 +217,10 @@ class CustomFieldChoice(models.Model):
|
||||
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
|
||||
|
||||
|
||||
#
|
||||
# Graphs
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Graph(models.Model):
|
||||
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
|
||||
@@ -238,6 +246,10 @@ class Graph(models.Model):
|
||||
return template.render(Context({'obj': obj}))
|
||||
|
||||
|
||||
#
|
||||
# Export templates
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class ExportTemplate(models.Model):
|
||||
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
|
||||
@@ -272,6 +284,10 @@ class ExportTemplate(models.Model):
|
||||
return response
|
||||
|
||||
|
||||
#
|
||||
# Topology maps
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class TopologyMap(models.Model):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
@@ -296,6 +312,56 @@ class TopologyMap(models.Model):
|
||||
return None
|
||||
return [line.strip() for line in self.device_patterns.split('\n')]
|
||||
|
||||
def render(self, img_format='png'):
|
||||
|
||||
from dcim.models import Device, InterfaceConnection
|
||||
|
||||
# Construct the graph
|
||||
graph = graphviz.Graph()
|
||||
graph.graph_attr['ranksep'] = '1'
|
||||
for i, device_set in enumerate(self.device_sets):
|
||||
|
||||
subgraph = graphviz.Graph(name='sg{}'.format(i))
|
||||
subgraph.graph_attr['rank'] = 'same'
|
||||
|
||||
# Add a pseudonode for each device_set to enforce hierarchical layout
|
||||
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
|
||||
if i:
|
||||
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
|
||||
|
||||
# Add each device to the graph
|
||||
devices = []
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
devices += Device.objects.filter(name__regex=query)
|
||||
for d in devices:
|
||||
subgraph.node(d.name)
|
||||
|
||||
# Add an invisible connection to each successive device in a set to enforce horizontal order
|
||||
for j in range(0, len(devices) - 1):
|
||||
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
|
||||
|
||||
graph.subgraph(subgraph)
|
||||
|
||||
# Compile list of all devices
|
||||
device_superset = Q()
|
||||
for device_set in self.device_sets:
|
||||
for query in device_set.split(';'): # Split regexes on semicolons
|
||||
device_superset = device_superset | Q(name__regex=query)
|
||||
|
||||
# Add all connections to the graph
|
||||
devices = Device.objects.filter(*(device_superset,))
|
||||
connections = InterfaceConnection.objects.filter(
|
||||
interface_a__device__in=devices, interface_b__device__in=devices
|
||||
)
|
||||
for c in connections:
|
||||
graph.edge(c.interface_a.device.name, c.interface_b.device.name)
|
||||
|
||||
return graph.pipe(format=img_format)
|
||||
|
||||
|
||||
#
|
||||
# User actions
|
||||
#
|
||||
|
||||
class UserActionManager(models.Manager):
|
||||
|
||||
@@ -330,9 +396,6 @@ class UserActionManager(models.Manager):
|
||||
def log_import(self, user, content_type, message=''):
|
||||
self.log_bulk_action(user, content_type, ACTION_IMPORT, message)
|
||||
|
||||
def log_bulk_create(self, user, content_type, message=''):
|
||||
self.log_bulk_action(user, content_type, ACTION_BULK_CREATE, message)
|
||||
|
||||
def log_bulk_edit(self, user, content_type, message=''):
|
||||
self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, message)
|
||||
|
||||
@@ -363,7 +426,7 @@ class UserAction(models.Model):
|
||||
return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
|
||||
|
||||
def icon(self):
|
||||
if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]:
|
||||
if self.action in [ACTION_CREATE, ACTION_IMPORT]:
|
||||
return mark_safe('<i class="glyphicon glyphicon-plus text-success"></i>')
|
||||
elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]:
|
||||
return mark_safe('<i class="glyphicon glyphicon-pencil text-warning"></i>')
|
||||
|
||||
@@ -33,14 +33,14 @@ class RPCClient(object):
|
||||
|
||||
def get_inventory(self):
|
||||
"""
|
||||
Returns a dictionary representing the device chassis and installed modules.
|
||||
Returns a dictionary representing the device chassis and installed inventory items.
|
||||
|
||||
{
|
||||
'chassis': {
|
||||
'serial': <str>,
|
||||
'description': <str>,
|
||||
}
|
||||
'modules': [
|
||||
'items': [
|
||||
{
|
||||
'name': <str>,
|
||||
'part_id': <str>,
|
||||
@@ -130,8 +130,11 @@ class JunosNC(RPCClient):
|
||||
for neighbor_raw in lldp_neighbors_raw:
|
||||
neighbor = dict()
|
||||
neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id')
|
||||
neighbor['name'] = neighbor_raw.get('lldp-remote-system-name')
|
||||
neighbor['name'] = neighbor['name'].split('.')[0] # Split hostname from domain if one is present
|
||||
name = neighbor_raw.get('lldp-remote-system-name')
|
||||
if name:
|
||||
neighbor['name'] = name.split('.')[0] # Split hostname from domain if one is present
|
||||
else:
|
||||
neighbor['name'] = ''
|
||||
try:
|
||||
neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description']
|
||||
except KeyError:
|
||||
@@ -144,23 +147,23 @@ class JunosNC(RPCClient):
|
||||
|
||||
def get_inventory(self):
|
||||
|
||||
def glean_modules(node, depth=0):
|
||||
modules = []
|
||||
modules_list = node.get('chassis{}-module'.format('-sub' * depth), [])
|
||||
def glean_items(node, depth=0):
|
||||
items = []
|
||||
items_list = node.get('chassis{}-module'.format('-sub' * depth), [])
|
||||
# Junos like to return single children directly instead of as a single-item list
|
||||
if hasattr(modules_list, 'items'):
|
||||
modules_list = [modules_list]
|
||||
for module in modules_list:
|
||||
if hasattr(items_list, 'items'):
|
||||
items_list = [items_list]
|
||||
for item in items_list:
|
||||
m = {
|
||||
'name': module['name'],
|
||||
'part_id': module.get('model-number') or module.get('part-number', ''),
|
||||
'serial': module.get('serial-number', ''),
|
||||
'name': item['name'],
|
||||
'part_id': item.get('model-number') or item.get('part-number', ''),
|
||||
'serial': item.get('serial-number', ''),
|
||||
}
|
||||
submodules = glean_modules(module, depth + 1)
|
||||
if submodules:
|
||||
m['modules'] = submodules
|
||||
modules.append(m)
|
||||
return modules
|
||||
child_items = glean_items(item, depth + 1)
|
||||
if child_items:
|
||||
m['items'] = child_items
|
||||
items.append(m)
|
||||
return items
|
||||
|
||||
rpc_reply = self.manager.dispatch('get-chassis-inventory')
|
||||
inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis']
|
||||
@@ -173,8 +176,8 @@ class JunosNC(RPCClient):
|
||||
'description': inventory_raw['description'],
|
||||
}
|
||||
|
||||
# Gather modules
|
||||
result['modules'] = glean_modules(inventory_raw)
|
||||
# Gather inventory items
|
||||
result['items'] = glean_items(inventory_raw)
|
||||
|
||||
return result
|
||||
|
||||
@@ -199,7 +202,7 @@ class IOSSSH(SSHClient):
|
||||
'description': parse(sh_ver, 'cisco ([^\s]+)')
|
||||
}
|
||||
|
||||
def modules(chassis_serial=None):
|
||||
def items(chassis_serial=None):
|
||||
cmd = self._send('show inventory').split('\r\n\r\n')
|
||||
for i in cmd:
|
||||
i_fmt = i.replace('\r\n', ' ')
|
||||
@@ -207,7 +210,7 @@ class IOSSSH(SSHClient):
|
||||
m_name = re.search('NAME: "([^"]+)"', i_fmt).group(1)
|
||||
m_pid = re.search('PID: ([^\s]+)', i_fmt).group(1)
|
||||
m_serial = re.search('SN: ([^\s]+)', i_fmt).group(1)
|
||||
# Omit built-in modules and those with no PID
|
||||
# Omit built-in items and those with no PID
|
||||
if m_serial != chassis_serial and m_pid.lower() != 'unspecified':
|
||||
yield {
|
||||
'name': m_name,
|
||||
@@ -222,7 +225,7 @@ class IOSSSH(SSHClient):
|
||||
|
||||
return {
|
||||
'chassis': sh_version,
|
||||
'modules': list(modules(chassis_serial=sh_version.get('serial')))
|
||||
'items': list(items(chassis_serial=sh_version.get('serial')))
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +260,7 @@ class OpengearSSH(SSHClient):
|
||||
'serial': serial,
|
||||
'description': description,
|
||||
},
|
||||
'modules': [],
|
||||
'items': [],
|
||||
}
|
||||
|
||||
|
||||
|
||||
168
netbox/extras/tests/test_api.py
Normal file
168
netbox/extras/tests/test_api.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.models import Graph, GRAPH_TYPE_SITE, ExportTemplate
|
||||
from users.models import Token
|
||||
from utilities.tests import HttpStatusMixin
|
||||
|
||||
|
||||
class GraphTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.graph1 = Graph.objects.create(
|
||||
type=GRAPH_TYPE_SITE, name='Test Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'
|
||||
)
|
||||
self.graph2 = Graph.objects.create(
|
||||
type=GRAPH_TYPE_SITE, name='Test Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'
|
||||
)
|
||||
self.graph3 = Graph.objects.create(
|
||||
type=GRAPH_TYPE_SITE, name='Test Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'
|
||||
)
|
||||
|
||||
def test_get_graph(self):
|
||||
|
||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.graph1.name)
|
||||
|
||||
def test_list_graphs(self):
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_graph(self):
|
||||
|
||||
data = {
|
||||
'type': GRAPH_TYPE_SITE,
|
||||
'name': 'Test Graph 4',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
|
||||
}
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.post(url, data, **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, data['type'])
|
||||
self.assertEqual(graph4.name, data['name'])
|
||||
self.assertEqual(graph4.source, data['source'])
|
||||
|
||||
def test_update_graph(self):
|
||||
|
||||
data = {
|
||||
'type': GRAPH_TYPE_SITE,
|
||||
'name': 'Test Graph X',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=99',
|
||||
}
|
||||
|
||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Graph.objects.count(), 3)
|
||||
graph1 = Graph.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(graph1.type, data['type'])
|
||||
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(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.content_type = ContentType.objects.get_for_model(Device)
|
||||
self.exporttemplate1 = ExportTemplate.objects.create(
|
||||
content_type=self.content_type, name='Test Export Template 1',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
)
|
||||
self.exporttemplate2 = ExportTemplate.objects.create(
|
||||
content_type=self.content_type, name='Test Export Template 2',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
)
|
||||
self.exporttemplate3 = ExportTemplate.objects.create(
|
||||
content_type=self.content_type, name='Test Export Template 3',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
)
|
||||
|
||||
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 = {
|
||||
'content_type': self.content_type.pk,
|
||||
'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, **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_id, data['content_type'])
|
||||
self.assertEqual(exporttemplate4.name, data['name'])
|
||||
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
||||
|
||||
def test_update_exporttemplate(self):
|
||||
|
||||
data = {
|
||||
'content_type': self.content_type.pk,
|
||||
'name': 'Test Export Template X',
|
||||
'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, **self.header)
|
||||
|
||||
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)
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||
import os
|
||||
import random
|
||||
|
||||
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
|
||||
secure_random = random.SystemRandom()
|
||||
print(''.join(secure_random.sample(charset, 50)))
|
||||
random.seed = (os.urandom(2048))
|
||||
print(''.join(random.choice(charset) for c in range(50)))
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer
|
||||
from extras.api.serializers import CustomFieldSerializer
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from tenancy.api.serializers import TenantNestedSerializer
|
||||
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from ipam.models import (
|
||||
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
|
||||
Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
|
||||
)
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
tenant = TenantNestedSerializer()
|
||||
class VRFSerializer(CustomFieldModelSerializer):
|
||||
tenant = NestedTenantSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
|
||||
|
||||
|
||||
class VRFNestedSerializer(VRFSerializer):
|
||||
class NestedVRFSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
|
||||
|
||||
class Meta(VRFSerializer.Meta):
|
||||
fields = ['id', 'name', 'rd']
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'url', 'name', 'rd']
|
||||
|
||||
|
||||
class VRFTenantSerializer(VRFSerializer):
|
||||
"""
|
||||
Include tenant serializer. Useful for determining tenant inheritance for Prefixes and IPAddresses.
|
||||
"""
|
||||
class WritableVRFSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(VRFSerializer.Meta):
|
||||
fields = ['id', 'name', 'rd', 'tenant']
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
|
||||
|
||||
#
|
||||
@@ -44,10 +49,12 @@ class RoleSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug', 'weight']
|
||||
|
||||
|
||||
class RoleNestedSerializer(RoleSerializer):
|
||||
class NestedRoleSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
|
||||
|
||||
class Meta(RoleSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
@@ -61,28 +68,39 @@ class RIRSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug', 'is_private']
|
||||
|
||||
|
||||
class RIRNestedSerializer(RIRSerializer):
|
||||
class NestedRIRSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
|
||||
|
||||
class Meta(RIRSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
rir = RIRNestedSerializer()
|
||||
class AggregateSerializer(CustomFieldModelSerializer):
|
||||
rir = NestedRIRSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
||||
|
||||
|
||||
class AggregateNestedSerializer(AggregateSerializer):
|
||||
class NestedAggregateSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
||||
|
||||
class Meta(AggregateSerializer.Meta):
|
||||
fields = ['id', 'family', 'prefix']
|
||||
model = Aggregate
|
||||
fields = ['id', 'url', 'family', 'prefix']
|
||||
|
||||
|
||||
class WritableAggregateSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description']
|
||||
|
||||
|
||||
#
|
||||
@@ -90,86 +108,155 @@ class AggregateNestedSerializer(AggregateSerializer):
|
||||
#
|
||||
|
||||
class VLANGroupSerializer(serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
site = NestedSiteSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['id', 'name', 'slug', 'site']
|
||||
|
||||
|
||||
class VLANGroupNestedSerializer(VLANGroupSerializer):
|
||||
class NestedVLANGroupSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
|
||||
|
||||
class Meta(VLANGroupSerializer.Meta):
|
||||
fields = ['id', 'name', 'slug']
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class WritableVLANGroupSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['id', 'name', 'slug', 'site']
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate uniqueness of name and slug if a site has been assigned.
|
||||
if data.get('site', None):
|
||||
for field in ['name', 'slug']:
|
||||
validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('site', field))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
group = VLANGroupNestedSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
role = RoleNestedSerializer()
|
||||
class VLANSerializer(CustomFieldModelSerializer):
|
||||
site = NestedSiteSerializer()
|
||||
group = NestedVLANGroupSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES)
|
||||
role = NestedRoleSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
||||
'custom_fields']
|
||||
fields = [
|
||||
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
class VLANNestedSerializer(VLANSerializer):
|
||||
class NestedVLANSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||
|
||||
class Meta(VLANSerializer.Meta):
|
||||
fields = ['id', 'vid', 'name', 'display_name']
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
||||
|
||||
|
||||
class WritableVLANSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate uniqueness of vid and name if a group has been assigned.
|
||||
if data.get('group', None):
|
||||
for field in ['vid', 'name']:
|
||||
validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
#
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
site = SiteNestedSerializer()
|
||||
vrf = VRFTenantSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
vlan = VLANNestedSerializer()
|
||||
role = RoleNestedSerializer()
|
||||
class PrefixSerializer(CustomFieldModelSerializer):
|
||||
site = NestedSiteSerializer()
|
||||
vrf = NestedVRFSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
vlan = NestedVLANSerializer()
|
||||
status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES)
|
||||
role = NestedRoleSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'custom_fields']
|
||||
fields = [
|
||||
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
class PrefixNestedSerializer(PrefixSerializer):
|
||||
class NestedPrefixSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
||||
|
||||
class Meta(PrefixSerializer.Meta):
|
||||
fields = ['id', 'family', 'prefix']
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['id', 'url', 'family', 'prefix']
|
||||
|
||||
|
||||
class WritablePrefixSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = ['id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description']
|
||||
|
||||
|
||||
#
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer):
|
||||
vrf = VRFTenantSerializer()
|
||||
tenant = TenantNestedSerializer()
|
||||
interface = InterfaceNestedSerializer()
|
||||
class IPAddressSerializer(CustomFieldModelSerializer):
|
||||
vrf = NestedVRFSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
|
||||
interface = InterfaceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside',
|
||||
'nat_outside', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside',
|
||||
'nat_outside', 'custom_fields',
|
||||
]
|
||||
|
||||
|
||||
class IPAddressNestedSerializer(IPAddressSerializer):
|
||||
class NestedIPAddressSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||
|
||||
class Meta(IPAddressSerializer.Meta):
|
||||
fields = ['id', 'family', 'address']
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['id', 'url', 'family', 'address']
|
||||
|
||||
IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
|
||||
IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer()
|
||||
IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
|
||||
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
|
||||
|
||||
|
||||
class WritableIPAddressSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside']
|
||||
|
||||
|
||||
#
|
||||
@@ -177,15 +264,17 @@ IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer(
|
||||
#
|
||||
|
||||
class ServiceSerializer(serializers.ModelSerializer):
|
||||
device = DeviceNestedSerializer()
|
||||
ipaddresses = IPAddressNestedSerializer(many=True)
|
||||
device = NestedDeviceSerializer()
|
||||
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
|
||||
ipaddresses = NestedIPAddressSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
|
||||
|
||||
class ServiceNestedSerializer(ServiceSerializer):
|
||||
class WritableServiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta(ServiceSerializer.Meta):
|
||||
fields = ['id', 'name', 'port', 'protocol']
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
from django.conf.urls import url
|
||||
from rest_framework import routers
|
||||
|
||||
from .views import *
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
class IPAMRootView(routers.APIRootView):
|
||||
"""
|
||||
IPAM API root view
|
||||
"""
|
||||
def get_view_name(self):
|
||||
return 'IPAM'
|
||||
|
||||
# VRFs
|
||||
url(r'^vrfs/$', VRFListView.as_view(), name='vrf_list'),
|
||||
url(r'^vrfs/(?P<pk>\d+)/$', VRFDetailView.as_view(), name='vrf_detail'),
|
||||
|
||||
# Roles
|
||||
url(r'^roles/$', RoleListView.as_view(), name='role_list'),
|
||||
url(r'^roles/(?P<pk>\d+)/$', RoleDetailView.as_view(), name='role_detail'),
|
||||
router = routers.DefaultRouter()
|
||||
router.APIRootView = IPAMRootView
|
||||
|
||||
# RIRs
|
||||
url(r'^rirs/$', RIRListView.as_view(), name='rir_list'),
|
||||
url(r'^rirs/(?P<pk>\d+)/$', RIRDetailView.as_view(), name='rir_detail'),
|
||||
# VRFs
|
||||
router.register(r'vrfs', views.VRFViewSet)
|
||||
|
||||
# Aggregates
|
||||
url(r'^aggregates/$', AggregateListView.as_view(), name='aggregate_list'),
|
||||
url(r'^aggregates/(?P<pk>\d+)/$', AggregateDetailView.as_view(), name='aggregate_detail'),
|
||||
# RIRs
|
||||
router.register(r'rirs', views.RIRViewSet)
|
||||
|
||||
# Prefixes
|
||||
url(r'^prefixes/$', PrefixListView.as_view(), name='prefix_list'),
|
||||
url(r'^prefixes/(?P<pk>\d+)/$', PrefixDetailView.as_view(), name='prefix_detail'),
|
||||
# Aggregates
|
||||
router.register(r'aggregates', views.AggregateViewSet)
|
||||
|
||||
# IP addresses
|
||||
url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
|
||||
# Prefixes
|
||||
router.register(r'roles', views.RoleViewSet)
|
||||
router.register(r'prefixes', views.PrefixViewSet)
|
||||
|
||||
# VLAN groups
|
||||
url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'),
|
||||
url(r'^vlan-groups/(?P<pk>\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'),
|
||||
# IP addresses
|
||||
router.register(r'ip-addresses', views.IPAddressViewSet)
|
||||
|
||||
# VLANs
|
||||
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
|
||||
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
|
||||
# VLANs
|
||||
router.register(r'vlan-groups', views.VLANGroupViewSet)
|
||||
router.register(r'vlans', views.VLANViewSet)
|
||||
|
||||
# Services
|
||||
url(r'^services/$', ServiceListView.as_view(), name='service_list'),
|
||||
url(r'^services/(?P<pk>\d+)/$', ServiceDetailView.as_view(), name='service_detail'),
|
||||
# Services
|
||||
router.register(r'services', views.ServiceViewSet)
|
||||
|
||||
]
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from rest_framework import generics
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from ipam import filters
|
||||
|
||||
from extras.api.views import CustomFieldModelAPIView
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from utilities.api import WritableSerializerMixin
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -11,39 +11,18 @@ from . import serializers
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List all VRFs
|
||||
"""
|
||||
queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field')
|
||||
class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.select_related('tenant')
|
||||
serializer_class = serializers.VRFSerializer
|
||||
write_serializer_class = serializers.WritableVRFSerializer
|
||||
filter_class = filters.VRFFilter
|
||||
|
||||
|
||||
class VRFDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single VRF
|
||||
"""
|
||||
queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.VRFSerializer
|
||||
|
||||
|
||||
#
|
||||
# Roles
|
||||
#
|
||||
|
||||
class RoleListView(generics.ListAPIView):
|
||||
"""
|
||||
List all roles
|
||||
"""
|
||||
queryset = Role.objects.all()
|
||||
serializer_class = serializers.RoleSerializer
|
||||
|
||||
|
||||
class RoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single role
|
||||
"""
|
||||
class RoleViewSet(ModelViewSet):
|
||||
queryset = Role.objects.all()
|
||||
serializer_class = serializers.RoleSerializer
|
||||
|
||||
@@ -52,18 +31,7 @@ class RoleDetailView(generics.RetrieveAPIView):
|
||||
# RIRs
|
||||
#
|
||||
|
||||
class RIRListView(generics.ListAPIView):
|
||||
"""
|
||||
List all RIRs
|
||||
"""
|
||||
queryset = RIR.objects.all()
|
||||
serializer_class = serializers.RIRSerializer
|
||||
|
||||
|
||||
class RIRDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single RIR
|
||||
"""
|
||||
class RIRViewSet(ModelViewSet):
|
||||
queryset = RIR.objects.all()
|
||||
serializer_class = serializers.RIRSerializer
|
||||
|
||||
@@ -72,129 +40,62 @@ class RIRDetailView(generics.RetrieveAPIView):
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List aggregates (filterable)
|
||||
"""
|
||||
queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field')
|
||||
class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = Aggregate.objects.select_related('rir')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
write_serializer_class = serializers.WritableAggregateSerializer
|
||||
filter_class = filters.AggregateFilter
|
||||
|
||||
|
||||
class AggregateDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single aggregate
|
||||
"""
|
||||
queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
|
||||
|
||||
#
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List prefixes (filterable)
|
||||
"""
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
write_serializer_class = serializers.WritablePrefixSerializer
|
||||
filter_class = filters.PrefixFilter
|
||||
|
||||
|
||||
class PrefixDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single prefix
|
||||
"""
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
|
||||
|
||||
#
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List IP addresses (filterable)
|
||||
"""
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
|
||||
.prefetch_related('nat_outside', 'custom_field_values__field')
|
||||
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
write_serializer_class = serializers.WritableIPAddressSerializer
|
||||
filter_class = filters.IPAddressFilter
|
||||
|
||||
|
||||
class IPAddressDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single IP address
|
||||
"""
|
||||
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
|
||||
.prefetch_related('nat_outside', 'custom_field_values__field')
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupListView(generics.ListAPIView):
|
||||
"""
|
||||
List all VLAN groups
|
||||
"""
|
||||
class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = VLANGroup.objects.select_related('site')
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
write_serializer_class = serializers.WritableVLANGroupSerializer
|
||||
filter_class = filters.VLANGroupFilter
|
||||
|
||||
|
||||
class VLANGroupDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single VLAN group
|
||||
"""
|
||||
queryset = VLANGroup.objects.select_related('site')
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANListView(CustomFieldModelAPIView, generics.ListAPIView):
|
||||
"""
|
||||
List VLANs (filterable)
|
||||
"""
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||
serializer_class = serializers.VLANSerializer
|
||||
write_serializer_class = serializers.WritableVLANSerializer
|
||||
filter_class = filters.VLANFilter
|
||||
|
||||
|
||||
class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single VLAN
|
||||
"""
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
|
||||
.prefetch_related('custom_field_values__field')
|
||||
serializer_class = serializers.VLANSerializer
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceListView(generics.ListAPIView):
|
||||
"""
|
||||
List services (filterable)
|
||||
"""
|
||||
queryset = Service.objects.select_related('device').prefetch_related('ipaddresses')
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
filter_class = filters.ServiceFilter
|
||||
|
||||
|
||||
class ServiceDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single service
|
||||
"""
|
||||
queryset = Service.objects.select_related('device').prefetch_related('ipaddresses')
|
||||
class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = Service.objects.select_related('device')
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
write_serializer_class = serializers.WritableServiceSerializer
|
||||
|
||||
@@ -6,7 +6,7 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
|
||||
ReturnURLForm, SlugField, add_blank_choice,
|
||||
SlugField, add_blank_choice,
|
||||
)
|
||||
|
||||
from .models import (
|
||||
@@ -210,33 +210,28 @@ class PrefixFromCSVForm(forms.ModelForm):
|
||||
site = self.cleaned_data.get('site')
|
||||
vlan_group_name = self.cleaned_data.get('vlan_group_name')
|
||||
vlan_vid = self.cleaned_data.get('vlan_vid')
|
||||
vlan_group = None
|
||||
vlan = None
|
||||
|
||||
# Validate VLAN group
|
||||
# Validate VLAN
|
||||
vlan_group = None
|
||||
if vlan_group_name:
|
||||
try:
|
||||
vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
|
||||
except VLANGroup.DoesNotExist:
|
||||
if site:
|
||||
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
|
||||
else:
|
||||
self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name))
|
||||
|
||||
# Validate VLAN
|
||||
if vlan_vid:
|
||||
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
|
||||
if vlan_vid and vlan_group:
|
||||
try:
|
||||
self.instance.vlan = VLAN.objects.get(site=site, group=vlan_group, vid=vlan_vid)
|
||||
self.instance.vlan = VLAN.objects.get(group=vlan_group, vid=vlan_vid)
|
||||
except VLAN.DoesNotExist:
|
||||
if site:
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
|
||||
elif vlan_group:
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for group {}.".format(vlan_vid, vlan_group_name))
|
||||
elif not vlan_group_name:
|
||||
self.add_error('vlan_vid', "Invalid global VLAN ID ({}).".format(vlan_vid))
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({} - {}).".format(vlan_group, vlan_vid))
|
||||
elif vlan_vid and site:
|
||||
try:
|
||||
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
|
||||
except VLAN.DoesNotExist:
|
||||
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
|
||||
except VLAN.MultipleObjectsReturned:
|
||||
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
|
||||
self.instance.vlan = vlan
|
||||
elif vlan_vid:
|
||||
self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -307,46 +302,21 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
|
||||
interface_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
|
||||
attrs={'filter-for': 'interface_rack'}
|
||||
)
|
||||
)
|
||||
interface_rack = forms.ModelChoiceField(
|
||||
queryset=Rack.objects.all(), required=False, label='Rack', widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{interface_site}}', display_field='display_name',
|
||||
attrs={'filter-for': 'interface_device'}
|
||||
)
|
||||
)
|
||||
interface_device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(), required=False, label='Device', widget=APISelect(
|
||||
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
|
||||
display_field='display_name', attrs={'filter-for': 'interface'}
|
||||
)
|
||||
)
|
||||
nat_site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
|
||||
attrs={'filter-for': 'nat_device'}
|
||||
)
|
||||
)
|
||||
nat_device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(), required=False, label='Device', widget=APISelect(
|
||||
api_url='/api/dcim/devices/?site_id={{nat_site}}', display_field='display_name',
|
||||
attrs={'filter-for': 'nat_inside'}
|
||||
)
|
||||
)
|
||||
livesearch = forms.CharField(
|
||||
required=False, label='IP Address', widget=Livesearch(
|
||||
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address'
|
||||
)
|
||||
class IPAddressForm(BootstrapMixin, CustomFieldForm):
|
||||
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
|
||||
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
|
||||
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
|
||||
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
|
||||
display_field='display_name',
|
||||
attrs={'filter-for': 'nat_inside'}))
|
||||
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
|
||||
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'nat_inside', 'description']
|
||||
fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
|
||||
widgets = {
|
||||
'interface': APISelect(api_url='/api/dcim/devices/{{interface_device}}/interfaces/'),
|
||||
'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
|
||||
}
|
||||
|
||||
@@ -355,37 +325,8 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
# If an interface has been assigned, initialize site, rack, and device
|
||||
if self.instance.interface:
|
||||
self.initial['interface_site'] = self.instance.interface.device.site
|
||||
self.initial['interface_rack'] = self.instance.interface.device.rack
|
||||
self.initial['interface_device'] = self.instance.interface.device
|
||||
|
||||
# Limit rack choices
|
||||
if self.is_bound and self.data.get('interface_site'):
|
||||
self.fields['interface_rack'].queryset = Rack.objects.filter(site__pk=self.data['interface_site'])
|
||||
elif self.initial.get('interface_site'):
|
||||
self.fields['interface_rack'].queryset = Rack.objects.filter(site=self.initial['interface_site'])
|
||||
else:
|
||||
self.fields['interface_rack'].choices = []
|
||||
|
||||
# Limit device choices
|
||||
if self.is_bound and self.data.get('interface_rack'):
|
||||
self.fields['interface_device'].queryset = Device.objects.filter(rack=self.data['interface_rack'])
|
||||
elif self.initial.get('interface_rack'):
|
||||
self.fields['interface_device'].queryset = Device.objects.filter(rack=self.initial['interface_rack'])
|
||||
else:
|
||||
self.fields['interface_device'].choices = []
|
||||
|
||||
# Limit interface choices
|
||||
if self.is_bound and self.data.get('interface_device'):
|
||||
self.fields['interface'].queryset = Interface.objects.filter(device=self.data['interface_device'])
|
||||
elif self.initial.get('interface_device'):
|
||||
self.fields['interface'].queryset = Interface.objects.filter(device=self.initial['interface_device'])
|
||||
else:
|
||||
self.fields['interface'].choices = []
|
||||
|
||||
if self.instance.nat_inside:
|
||||
|
||||
nat_inside = self.instance.nat_inside
|
||||
# If the IP is assigned to an interface, populate site/device fields accordingly
|
||||
if self.instance.nat_inside.interface:
|
||||
@@ -399,7 +340,9 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
|
||||
)
|
||||
else:
|
||||
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
|
||||
|
||||
else:
|
||||
|
||||
# Initialize nat_device choices if nat_site is set
|
||||
if self.is_bound and self.data.get('nat_site'):
|
||||
self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
|
||||
@@ -407,6 +350,7 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
|
||||
self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
|
||||
else:
|
||||
self.fields['nat_device'].choices = []
|
||||
|
||||
# Initialize nat_inside choices if nat_device is set
|
||||
if self.is_bound and self.data.get('nat_device'):
|
||||
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
|
||||
@@ -418,15 +362,12 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
|
||||
self.fields['nat_inside'].choices = []
|
||||
|
||||
|
||||
class IPAddressBulkAddForm(BootstrapMixin, CustomFieldForm):
|
||||
address_pattern = ExpandableIPAddressField(label='Address Pattern')
|
||||
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
|
||||
address = ExpandableIPAddressField()
|
||||
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
|
||||
|
||||
pattern_map = ('address_pattern', 'address')
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['address_pattern', 'vrf', 'tenant', 'status', 'description']
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
@@ -645,51 +586,27 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
|
||||
|
||||
|
||||
class VLANFromCSVForm(forms.ModelForm):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Site not found.'}
|
||||
)
|
||||
group_name = forms.CharField(required=False)
|
||||
tenant = forms.ModelChoiceField(
|
||||
Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'}
|
||||
)
|
||||
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Site not found.'})
|
||||
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'VLAN group not found.'})
|
||||
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
|
||||
error_messages={'invalid_choice': 'Tenant not found.'})
|
||||
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
|
||||
role = forms.ModelChoiceField(
|
||||
queryset=Role.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid role.'}
|
||||
)
|
||||
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid role.'})
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
|
||||
|
||||
def clean(self):
|
||||
|
||||
super(VLANFromCSVForm, self).clean()
|
||||
|
||||
# Validate VLANGroup
|
||||
group_name = self.cleaned_data.get('group_name')
|
||||
if group_name:
|
||||
try:
|
||||
vlan_group = VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
|
||||
except VLANGroup.DoesNotExist:
|
||||
self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
|
||||
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
vlan = super(VLANFromCSVForm, self).save(commit=False)
|
||||
|
||||
# Assign VLANGroup by site and name
|
||||
if self.cleaned_data['group_name']:
|
||||
vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
|
||||
|
||||
m = super(VLANFromCSVForm, self).save(commit=False)
|
||||
# Assign VLAN status by name
|
||||
vlan.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
|
||||
|
||||
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
|
||||
if kwargs.get('commit'):
|
||||
vlan.save()
|
||||
return vlan
|
||||
m.save()
|
||||
return m
|
||||
|
||||
|
||||
class VLANImportForm(BootstrapMixin, BulkImportForm):
|
||||
|
||||
660
netbox/ipam/tests/test_api.py
Normal file
660
netbox/ipam/tests/test_api.py
Normal file
@@ -0,0 +1,660 @@
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
from ipam.models import (
|
||||
Aggregate, IPAddress, IP_PROTOCOL_TCP, IP_PROTOCOL_UDP, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF,
|
||||
)
|
||||
from users.models import Token
|
||||
from utilities.tests import HttpStatusMixin
|
||||
|
||||
|
||||
class VRFTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1')
|
||||
self.vrf2 = VRF.objects.create(name='Test VRF 2', rd='65000:2')
|
||||
self.vrf3 = VRF.objects.create(name='Test VRF 3', rd='65000:3')
|
||||
|
||||
def test_get_vrf(self):
|
||||
|
||||
url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.vrf1.name)
|
||||
|
||||
def test_list_vrfs(self):
|
||||
|
||||
url = reverse('ipam-api:vrf-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_vrf(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test VRF 4',
|
||||
'rd': '65000:4',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vrf-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VRF.objects.count(), 4)
|
||||
vrf4 = VRF.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(vrf4.name, data['name'])
|
||||
self.assertEqual(vrf4.rd, data['rd'])
|
||||
|
||||
def test_update_vrf(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test VRF X',
|
||||
'rd': '65000:99',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VRF.objects.count(), 3)
|
||||
vrf1 = VRF.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(vrf1.name, data['name'])
|
||||
self.assertEqual(vrf1.rd, data['rd'])
|
||||
|
||||
def test_delete_vrf(self):
|
||||
|
||||
url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(VRF.objects.count(), 2)
|
||||
|
||||
|
||||
class RIRTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1')
|
||||
self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2')
|
||||
self.rir3 = RIR.objects.create(name='Test RIR 3', slug='test-rir-3')
|
||||
|
||||
def test_get_rir(self):
|
||||
|
||||
url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.rir1.name)
|
||||
|
||||
def test_list_rirs(self):
|
||||
|
||||
url = reverse('ipam-api:rir-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_rir(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test RIR 4',
|
||||
'slug': 'test-rir-4',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:rir-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(RIR.objects.count(), 4)
|
||||
rir4 = RIR.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(rir4.name, data['name'])
|
||||
self.assertEqual(rir4.slug, data['slug'])
|
||||
|
||||
def test_update_rir(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test RIR X',
|
||||
'slug': 'test-rir-x',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(RIR.objects.count(), 3)
|
||||
rir1 = RIR.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(rir1.name, data['name'])
|
||||
self.assertEqual(rir1.slug, data['slug'])
|
||||
|
||||
def test_delete_rir(self):
|
||||
|
||||
url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(RIR.objects.count(), 2)
|
||||
|
||||
|
||||
class AggregateTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.rir1 = RIR.objects.create(name='Test RIR 1', slug='test-rir-1')
|
||||
self.rir2 = RIR.objects.create(name='Test RIR 2', slug='test-rir-2')
|
||||
self.aggregate1 = Aggregate.objects.create(prefix=IPNetwork('10.0.0.0/8'), rir=self.rir1)
|
||||
self.aggregate2 = Aggregate.objects.create(prefix=IPNetwork('172.16.0.0/12'), rir=self.rir1)
|
||||
self.aggregate3 = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=self.rir1)
|
||||
|
||||
def test_get_aggregate(self):
|
||||
|
||||
url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['prefix'], str(self.aggregate1.prefix))
|
||||
|
||||
def test_list_aggregates(self):
|
||||
|
||||
url = reverse('ipam-api:aggregate-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_aggregate(self):
|
||||
|
||||
data = {
|
||||
'prefix': '192.0.2.0/24',
|
||||
'rir': self.rir1.pk,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:aggregate-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Aggregate.objects.count(), 4)
|
||||
aggregate4 = Aggregate.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(str(aggregate4.prefix), data['prefix'])
|
||||
self.assertEqual(aggregate4.rir_id, data['rir'])
|
||||
|
||||
def test_update_aggregate(self):
|
||||
|
||||
data = {
|
||||
'prefix': '11.0.0.0/8',
|
||||
'rir': self.rir2.pk,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Aggregate.objects.count(), 3)
|
||||
aggregate1 = Aggregate.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(str(aggregate1.prefix), data['prefix'])
|
||||
self.assertEqual(aggregate1.rir_id, data['rir'])
|
||||
|
||||
def test_delete_aggregate(self):
|
||||
|
||||
url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Aggregate.objects.count(), 2)
|
||||
|
||||
|
||||
class RoleTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.role1 = Role.objects.create(name='Test Role 1', slug='test-role-1')
|
||||
self.role2 = Role.objects.create(name='Test Role 2', slug='test-role-2')
|
||||
self.role3 = Role.objects.create(name='Test Role 3', slug='test-role-3')
|
||||
|
||||
def test_get_role(self):
|
||||
|
||||
url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.role1.name)
|
||||
|
||||
def test_list_roles(self):
|
||||
|
||||
url = reverse('ipam-api:role-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_role(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Role 4',
|
||||
'slug': 'test-role-4',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:role-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Role.objects.count(), 4)
|
||||
role4 = Role.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(role4.name, data['name'])
|
||||
self.assertEqual(role4.slug, data['slug'])
|
||||
|
||||
def test_update_role(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Role X',
|
||||
'slug': 'test-role-x',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Role.objects.count(), 3)
|
||||
role1 = Role.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(role1.name, data['name'])
|
||||
self.assertEqual(role1.slug, data['slug'])
|
||||
|
||||
def test_delete_role(self):
|
||||
|
||||
url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Role.objects.count(), 2)
|
||||
|
||||
|
||||
class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1')
|
||||
self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1')
|
||||
self.role1 = Role.objects.create(name='Test Role 1', slug='test-role-1')
|
||||
self.prefix1 = Prefix.objects.create(prefix=IPNetwork('192.168.1.0/24'))
|
||||
self.prefix2 = Prefix.objects.create(prefix=IPNetwork('192.168.2.0/24'))
|
||||
self.prefix3 = Prefix.objects.create(prefix=IPNetwork('192.168.3.0/24'))
|
||||
|
||||
def test_get_prefix(self):
|
||||
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['prefix'], str(self.prefix1.prefix))
|
||||
|
||||
def test_list_prefixs(self):
|
||||
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_prefix(self):
|
||||
|
||||
data = {
|
||||
'prefix': '192.168.4.0/24',
|
||||
'site': self.site1.pk,
|
||||
'vrf': self.vrf1.pk,
|
||||
'vlan': self.vlan1.pk,
|
||||
'role': self.role1.pk,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Prefix.objects.count(), 4)
|
||||
prefix4 = Prefix.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(str(prefix4.prefix), data['prefix'])
|
||||
self.assertEqual(prefix4.site_id, data['site'])
|
||||
self.assertEqual(prefix4.vrf_id, data['vrf'])
|
||||
self.assertEqual(prefix4.vlan_id, data['vlan'])
|
||||
self.assertEqual(prefix4.role_id, data['role'])
|
||||
|
||||
def test_update_prefix(self):
|
||||
|
||||
data = {
|
||||
'prefix': '192.168.99.0/24',
|
||||
'site': self.site1.pk,
|
||||
'vrf': self.vrf1.pk,
|
||||
'vlan': self.vlan1.pk,
|
||||
'role': self.role1.pk,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Prefix.objects.count(), 3)
|
||||
prefix1 = Prefix.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(str(prefix1.prefix), data['prefix'])
|
||||
self.assertEqual(prefix1.site_id, data['site'])
|
||||
self.assertEqual(prefix1.vrf_id, data['vrf'])
|
||||
self.assertEqual(prefix1.vlan_id, data['vlan'])
|
||||
self.assertEqual(prefix1.role_id, data['role'])
|
||||
|
||||
def test_delete_prefix(self):
|
||||
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Prefix.objects.count(), 2)
|
||||
|
||||
|
||||
class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.vrf1 = VRF.objects.create(name='Test VRF 1', rd='65000:1')
|
||||
self.ipaddress1 = IPAddress.objects.create(address=IPNetwork('192.168.0.1/24'))
|
||||
self.ipaddress2 = IPAddress.objects.create(address=IPNetwork('192.168.0.2/24'))
|
||||
self.ipaddress3 = IPAddress.objects.create(address=IPNetwork('192.168.0.3/24'))
|
||||
|
||||
def test_get_ipaddress(self):
|
||||
|
||||
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['address'], str(self.ipaddress1.address))
|
||||
|
||||
def test_list_ipaddresss(self):
|
||||
|
||||
url = reverse('ipam-api:ipaddress-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_ipaddress(self):
|
||||
|
||||
data = {
|
||||
'address': '192.168.0.4/24',
|
||||
'vrf': self.vrf1.pk,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:ipaddress-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(IPAddress.objects.count(), 4)
|
||||
ipaddress4 = IPAddress.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(str(ipaddress4.address), data['address'])
|
||||
self.assertEqual(ipaddress4.vrf_id, data['vrf'])
|
||||
|
||||
def test_update_ipaddress(self):
|
||||
|
||||
data = {
|
||||
'address': '192.168.0.99/24',
|
||||
'vrf': self.vrf1.pk,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(IPAddress.objects.count(), 3)
|
||||
ipaddress1 = IPAddress.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(str(ipaddress1.address), data['address'])
|
||||
self.assertEqual(ipaddress1.vrf_id, data['vrf'])
|
||||
|
||||
def test_delete_ipaddress(self):
|
||||
|
||||
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(IPAddress.objects.count(), 2)
|
||||
|
||||
|
||||
class VLANGroupTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1')
|
||||
self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2')
|
||||
self.vlangroup3 = VLANGroup.objects.create(name='Test VLAN Group 3', slug='test-vlan-group-3')
|
||||
|
||||
def test_get_vlangroup(self):
|
||||
|
||||
url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.vlangroup1.name)
|
||||
|
||||
def test_list_vlangroups(self):
|
||||
|
||||
url = reverse('ipam-api:vlangroup-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_vlangroup(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test VLAN Group 4',
|
||||
'slug': 'test-vlan-group-4',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlangroup-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLANGroup.objects.count(), 4)
|
||||
vlangroup4 = VLANGroup.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(vlangroup4.name, data['name'])
|
||||
self.assertEqual(vlangroup4.slug, data['slug'])
|
||||
|
||||
def test_update_vlangroup(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test VLAN Group X',
|
||||
'slug': 'test-vlan-group-x',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VLANGroup.objects.count(), 3)
|
||||
vlangroup1 = VLANGroup.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(vlangroup1.name, data['name'])
|
||||
self.assertEqual(vlangroup1.slug, data['slug'])
|
||||
|
||||
def test_delete_vlangroup(self):
|
||||
|
||||
url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(VLANGroup.objects.count(), 2)
|
||||
|
||||
|
||||
class VLANTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1')
|
||||
self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2')
|
||||
self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3')
|
||||
|
||||
def test_get_vlan(self):
|
||||
|
||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.vlan1.name)
|
||||
|
||||
def test_list_vlans(self):
|
||||
|
||||
url = reverse('ipam-api:vlan-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_vlan(self):
|
||||
|
||||
data = {
|
||||
'vid': 4,
|
||||
'name': 'Test VLAN 4',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlan-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLAN.objects.count(), 4)
|
||||
vlan4 = VLAN.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(vlan4.vid, data['vid'])
|
||||
self.assertEqual(vlan4.name, data['name'])
|
||||
|
||||
def test_update_vlan(self):
|
||||
|
||||
data = {
|
||||
'vid': 99,
|
||||
'name': 'Test VLAN X',
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VLAN.objects.count(), 3)
|
||||
vlan1 = VLAN.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(vlan1.vid, data['vid'])
|
||||
self.assertEqual(vlan1.name, data['name'])
|
||||
|
||||
def test_delete_vlan(self):
|
||||
|
||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(VLAN.objects.count(), 2)
|
||||
|
||||
|
||||
class ServiceTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device Type 1')
|
||||
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1')
|
||||
self.device1 = Device.objects.create(
|
||||
name='Test Device 1', site=site, device_type=devicetype, device_role=devicerole
|
||||
)
|
||||
self.device2 = Device.objects.create(
|
||||
name='Test Device 2', site=site, device_type=devicetype, device_role=devicerole
|
||||
)
|
||||
self.service1 = Service.objects.create(
|
||||
device=self.device1, name='Test Service 1', protocol=IP_PROTOCOL_TCP, port=1
|
||||
)
|
||||
self.service1 = Service.objects.create(
|
||||
device=self.device1, name='Test Service 2', protocol=IP_PROTOCOL_TCP, port=2
|
||||
)
|
||||
self.service1 = Service.objects.create(
|
||||
device=self.device1, name='Test Service 3', protocol=IP_PROTOCOL_TCP, port=3
|
||||
)
|
||||
|
||||
def test_get_service(self):
|
||||
|
||||
url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.service1.name)
|
||||
|
||||
def test_list_services(self):
|
||||
|
||||
url = reverse('ipam-api:service-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_service(self):
|
||||
|
||||
data = {
|
||||
'device': self.device1.pk,
|
||||
'name': 'Test Service 4',
|
||||
'protocol': IP_PROTOCOL_TCP,
|
||||
'port': 4,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:service-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Service.objects.count(), 4)
|
||||
service4 = Service.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(service4.device_id, data['device'])
|
||||
self.assertEqual(service4.name, data['name'])
|
||||
self.assertEqual(service4.protocol, data['protocol'])
|
||||
self.assertEqual(service4.port, data['port'])
|
||||
|
||||
def test_update_service(self):
|
||||
|
||||
data = {
|
||||
'device': self.device2.pk,
|
||||
'name': 'Test Service X',
|
||||
'protocol': IP_PROTOCOL_UDP,
|
||||
'port': 99,
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Service.objects.count(), 3)
|
||||
service1 = Service.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(service1.device_id, data['device'])
|
||||
self.assertEqual(service1.name, data['name'])
|
||||
self.assertEqual(service1.protocol, data['protocol'])
|
||||
self.assertEqual(service1.port, data['port'])
|
||||
|
||||
def test_delete_service(self):
|
||||
|
||||
url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Service.objects.count(), 2)
|
||||
@@ -57,6 +57,8 @@ urlpatterns = [
|
||||
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'),
|
||||
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
|
||||
|
||||
# VLAN groups
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from django_tables2 import RequestConfig
|
||||
import netaddr
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.contrib import messages
|
||||
@@ -244,7 +243,7 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = RIR
|
||||
form_class = forms.RIRForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('ipam:rir_list')
|
||||
|
||||
|
||||
@@ -296,12 +295,7 @@ def aggregate(request, pk):
|
||||
prefix_table = tables.PrefixTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
prefix_table.base_columns['pk'].visible = True
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(prefix_table)
|
||||
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
@@ -370,7 +364,7 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Role
|
||||
form_class = forms.RoleForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('ipam:role_list')
|
||||
|
||||
|
||||
@@ -433,12 +427,7 @@ def prefix(request, pk):
|
||||
child_prefix_table = tables.PrefixTable(child_prefixes)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
child_prefix_table.base_columns['pk'].visible = True
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(child_prefix_table)
|
||||
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
@@ -464,6 +453,7 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = Prefix
|
||||
form_class = forms.PrefixForm
|
||||
template_name = 'ipam/prefix_edit.html'
|
||||
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
|
||||
default_return_url = 'ipam:prefix_list'
|
||||
|
||||
|
||||
@@ -510,12 +500,7 @@ def prefix_ipaddresses(request, pk):
|
||||
ip_table = tables.IPAddressTable(ipaddresses)
|
||||
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
|
||||
ip_table.base_columns['pk'].visible = True
|
||||
|
||||
paginate = {
|
||||
'klass': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(ip_table)
|
||||
RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
@@ -571,10 +556,80 @@ def ipaddress(request, pk):
|
||||
})
|
||||
|
||||
|
||||
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
|
||||
def ipaddress_assign(request, pk):
|
||||
|
||||
ipaddress = get_object_or_404(IPAddress, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = forms.IPAddressAssignForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
interface = form.cleaned_data['interface']
|
||||
ipaddress.interface = interface
|
||||
ipaddress.save()
|
||||
messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
|
||||
|
||||
if form.cleaned_data['set_as_primary']:
|
||||
device = interface.device
|
||||
if ipaddress.family == 4:
|
||||
device.primary_ip4 = ipaddress
|
||||
elif ipaddress.family == 6:
|
||||
device.primary_ip6 = ipaddress
|
||||
device.save()
|
||||
|
||||
return redirect('ipam:ipaddress', pk=ipaddress.pk)
|
||||
else:
|
||||
assert False, form.errors
|
||||
|
||||
else:
|
||||
form = forms.IPAddressAssignForm()
|
||||
|
||||
return render(request, 'ipam/ipaddress_assign.html', {
|
||||
'ipaddress': ipaddress,
|
||||
'form': form,
|
||||
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
|
||||
})
|
||||
|
||||
|
||||
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
|
||||
def ipaddress_remove(request, pk):
|
||||
|
||||
ipaddress = get_object_or_404(IPAddress, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ConfirmationForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
device = ipaddress.interface.device
|
||||
ipaddress.interface = None
|
||||
ipaddress.save()
|
||||
messages.success(request, u"Removed IP address {} from {}.".format(ipaddress, device))
|
||||
|
||||
if device.primary_ip4 == ipaddress.pk:
|
||||
device.primary_ip4 = None
|
||||
device.save()
|
||||
elif device.primary_ip6 == ipaddress.pk:
|
||||
device.primary_ip6 = None
|
||||
device.save()
|
||||
|
||||
return redirect('ipam:ipaddress', pk=ipaddress.pk)
|
||||
|
||||
else:
|
||||
form = ConfirmationForm()
|
||||
|
||||
return render(request, 'ipam/ipaddress_unassign.html', {
|
||||
'ipaddress': ipaddress,
|
||||
'form': form,
|
||||
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
|
||||
})
|
||||
|
||||
|
||||
class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'ipam.change_ipaddress'
|
||||
model = IPAddress
|
||||
form_class = forms.IPAddressForm
|
||||
fields_initial = ['address', 'vrf']
|
||||
template_name = 'ipam/ipaddress_edit.html'
|
||||
default_return_url = 'ipam:ipaddress_list'
|
||||
|
||||
@@ -588,7 +643,7 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
|
||||
permission_required = 'ipam.add_ipaddress'
|
||||
form = forms.IPAddressBulkAddForm
|
||||
model_form = forms.IPAddressForm
|
||||
model = IPAddress
|
||||
template_name = 'ipam/ipaddress_bulk_add.html'
|
||||
default_return_url = 'ipam:ipaddress_list'
|
||||
|
||||
@@ -647,7 +702,7 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = VLANGroup
|
||||
form_class = forms.VLANGroupForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('ipam:vlangroup_list')
|
||||
|
||||
|
||||
@@ -736,7 +791,7 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
|
||||
return obj
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return obj.device.get_absolute_url()
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,26 @@ ADMINS = [
|
||||
# ['John Doe', 'jdoe@example.com'],
|
||||
]
|
||||
|
||||
# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both
|
||||
# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
|
||||
BANNER_TOP = ''
|
||||
BANNER_BOTTOM = ''
|
||||
|
||||
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
|
||||
# API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be
|
||||
# allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or
|
||||
# CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers
|
||||
CORS_ORIGIN_ALLOW_ALL = False
|
||||
CORS_ORIGIN_WHITELIST = [
|
||||
# 'hostname.example.com',
|
||||
]
|
||||
CORS_ORIGIN_REGEX_WHITELIST = [
|
||||
# r'^(https?://)?(\w+\.)?example\.com$',
|
||||
]
|
||||
|
||||
# Email settings
|
||||
EMAIL = {
|
||||
'SERVER': 'localhost',
|
||||
@@ -48,24 +68,28 @@ EMAIL = {
|
||||
'FROM_EMAIL': '',
|
||||
}
|
||||
|
||||
# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
|
||||
# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
|
||||
ENFORCE_GLOBAL_UNIQUE = False
|
||||
|
||||
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
|
||||
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
|
||||
LOGIN_REQUIRED = False
|
||||
|
||||
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
|
||||
# Setting this to True will display a "maintenance mode" banner at the top of every page.
|
||||
MAINTENANCE_MODE = False
|
||||
|
||||
# Credentials that NetBox will use to access live devices.
|
||||
# Credentials that NetBox will use to access live devices (future use).
|
||||
NETBOX_USERNAME = ''
|
||||
NETBOX_PASSWORD = ''
|
||||
|
||||
# Determine how many objects to display per page within a list. (Default: 50)
|
||||
PAGINATE_COUNT = 50
|
||||
|
||||
# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
|
||||
# prefer IPv4 instead.
|
||||
PREFER_IPV4 = False
|
||||
|
||||
# Time zone (default: UTC)
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
@@ -77,16 +101,3 @@ TIME_FORMAT = 'g:i a'
|
||||
SHORT_TIME_FORMAT = 'H:i:s'
|
||||
DATETIME_FORMAT = 'N j, Y g:i a'
|
||||
SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
|
||||
|
||||
# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both
|
||||
# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
|
||||
BANNER_TOP = ''
|
||||
BANNER_BOTTOM = ''
|
||||
|
||||
# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
|
||||
# prefer IPv4 instead.
|
||||
PREFER_IPV4 = False
|
||||
|
||||
# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
|
||||
# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
|
||||
ENFORCE_GLOBAL_UNIQUE = False
|
||||
|
||||
@@ -8,19 +8,22 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
try:
|
||||
from netbox import configuration
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per "
|
||||
"the documentation.")
|
||||
raise ImproperlyConfigured(
|
||||
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
|
||||
)
|
||||
|
||||
|
||||
VERSION = '1.9.6'
|
||||
VERSION = '2.0-beta1'
|
||||
|
||||
# Import local configuration
|
||||
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
|
||||
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
|
||||
try:
|
||||
globals()[setting] = getattr(configuration, setting)
|
||||
except AttributeError:
|
||||
raise ImproperlyConfigured("Mandatory setting {} is missing from configuration.py. Please define it per the "
|
||||
"documentation.".format(setting))
|
||||
raise ImproperlyConfigured(
|
||||
"Mandatory setting {} is missing from configuration.py.".format(setting)
|
||||
)
|
||||
|
||||
# Default configurations
|
||||
ADMINS = getattr(configuration, 'ADMINS', [])
|
||||
@@ -45,6 +48,9 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
|
||||
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
|
||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
|
||||
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
|
||||
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
|
||||
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||
|
||||
# Attempt to import LDAP configuration if it has been defined
|
||||
@@ -73,8 +79,10 @@ if LDAP_CONFIGURED:
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
logger.setLevel(logging.DEBUG)
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
|
||||
"You can remove netbox/ldap_config.py to disable LDAP.")
|
||||
raise ImproperlyConfigured(
|
||||
"LDAP authentication has been configured, but django-auth-ldap is not installed. You can remove "
|
||||
"netbox/ldap_config.py to disable LDAP."
|
||||
)
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -102,6 +110,7 @@ INSTALLED_APPS = (
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'corsheaders',
|
||||
'debug_toolbar',
|
||||
'django_tables2',
|
||||
'mptt',
|
||||
@@ -120,6 +129,7 @@ INSTALLED_APPS = (
|
||||
# Middleware
|
||||
MIDDLEWARE = (
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
@@ -129,6 +139,7 @@ MIDDLEWARE = (
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'utilities.middleware.LoginRequiredMiddleware',
|
||||
'utilities.middleware.APIVersionMiddleware',
|
||||
)
|
||||
|
||||
ROOT_URLCONF = 'netbox.urls'
|
||||
@@ -183,12 +194,25 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
|
||||
# Secrets
|
||||
SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||
|
||||
# Django REST framework
|
||||
# Django REST framework (API)
|
||||
REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',)
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'utilities.api.TokenAuthentication',
|
||||
),
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
'rest_framework.filters.DjangoFilterBackend',
|
||||
),
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'utilities.api.TokenPermissions',
|
||||
),
|
||||
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
|
||||
'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
|
||||
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
|
||||
'PAGE_SIZE': PAGINATE_COUNT,
|
||||
}
|
||||
if LOGIN_REQUIRED:
|
||||
REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
|
||||
|
||||
# Django debug toolbar
|
||||
INTERNAL_IPS = (
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
from rest_framework_swagger.views import get_swagger_view
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.contrib import admin
|
||||
|
||||
from netbox.views import home, handle_500, trigger_500
|
||||
from netbox.views import APIRootView, home, handle_500, trigger_500
|
||||
from users.views import login, logout
|
||||
|
||||
|
||||
handler500 = handle_500
|
||||
swagger_view = get_swagger_view(title='NetBox API')
|
||||
|
||||
_patterns = [
|
||||
|
||||
@@ -29,13 +26,14 @@ _patterns = [
|
||||
url(r'^user/', include('users.urls', namespace='user')),
|
||||
|
||||
# API
|
||||
url(r'^api/$', APIRootView.as_view(), name='api-root'),
|
||||
url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')),
|
||||
url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
|
||||
url(r'^api/extras/', include('extras.api.urls', namespace='extras-api')),
|
||||
url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
|
||||
url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
|
||||
url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
|
||||
url(r'^api/docs/', swagger_view, name='api_docs'),
|
||||
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
url(r'^api/docs/', include('rest_framework_swagger.urls')),
|
||||
|
||||
# Error testing
|
||||
url(r'^500/$', trigger_500),
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import sys
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from django.shortcuts import render
|
||||
|
||||
from circuits.models import Provider, Circuit
|
||||
@@ -47,6 +52,25 @@ def home(request):
|
||||
})
|
||||
|
||||
|
||||
class APIRootView(APIView):
|
||||
_ignore_model_permissions = True
|
||||
exclude_from_schema = True
|
||||
|
||||
def get_view_name(self):
|
||||
return u"API Root"
|
||||
|
||||
def get(self, request, format=None):
|
||||
|
||||
return Response({
|
||||
'circuits': reverse('circuits-api:api-root', request=request, format=format),
|
||||
'dcim': reverse('dcim-api:api-root', request=request, format=format),
|
||||
'extras': reverse('extras-api:api-root', request=request, format=format),
|
||||
'ipam': reverse('ipam-api:api-root', request=request, format=format),
|
||||
'secrets': reverse('secrets-api:api-root', request=request, format=format),
|
||||
'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
|
||||
})
|
||||
|
||||
|
||||
def handle_500(request):
|
||||
"""
|
||||
Custom server error handler
|
||||
|
||||
@@ -313,16 +313,6 @@ li.occupied + li.available {
|
||||
border-top: 1px solid #474747;
|
||||
}
|
||||
|
||||
/* Devices */
|
||||
table.component-list tr.ipaddress td {
|
||||
background-color: #eeffff;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
table.component-list tr.ipaddress:hover td {
|
||||
background-color: #e6f7f7;
|
||||
}
|
||||
|
||||
/* Misc */
|
||||
.banner-bottom {
|
||||
margin-bottom: 50px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
$(document).ready(function() {
|
||||
|
||||
// "Toggle all" checkbox (table header)
|
||||
$('#toggle_all').click(function (event) {
|
||||
$('#toggle_all').click(function() {
|
||||
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||
if ($(this).is(':checked')) {
|
||||
$('#select_all_box').removeClass('hidden');
|
||||
@@ -10,7 +10,7 @@ $(document).ready(function() {
|
||||
}
|
||||
});
|
||||
// Enable hidden buttons when "select all" is checked
|
||||
$('#select_all').click(function (event) {
|
||||
$('#select_all').click(function() {
|
||||
if ($(this).is(':checked')) {
|
||||
$('#select_all_box').find('button').prop('disabled', '');
|
||||
} else {
|
||||
@@ -25,7 +25,7 @@ $(document).ready(function() {
|
||||
});
|
||||
|
||||
// Simple "Toggle all" button (panel)
|
||||
$('button.toggle').click(function (event) {
|
||||
$('button.toggle').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
|
||||
$(this).attr('selected', !selected);
|
||||
@@ -55,12 +55,12 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
// Bulk edit nullification
|
||||
$('input:checkbox[name=_nullify]').click(function (event) {
|
||||
$('input:checkbox[name=_nullify]').click(function() {
|
||||
$('#id_' + this.value).toggle('disabled');
|
||||
});
|
||||
|
||||
// Set formaction and submit using a link
|
||||
$('a.formaction').click(function (event) {
|
||||
$('a.formaction').click(function(event) {
|
||||
event.preventDefault();
|
||||
var form = $(this).closest('form');
|
||||
form.attr('action', $(this).attr('href'));
|
||||
@@ -103,8 +103,8 @@ $(document).ready(function() {
|
||||
$.ajax({
|
||||
url: api_url,
|
||||
dataType: 'json',
|
||||
success: function (response, status) {
|
||||
$.each(response, function (index, choice) {
|
||||
success: function(response, status) {
|
||||
$.each(response.results, function(index, choice) {
|
||||
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
|
||||
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
|
||||
option.attr("disabled", "disabled");
|
||||
|
||||
@@ -1,54 +1,94 @@
|
||||
$(document).ready(function() {
|
||||
|
||||
// Unlocking a secret
|
||||
$('button.unlock-secret').click(function (event) {
|
||||
$('button.unlock-secret').click(function() {
|
||||
var secret_id = $(this).attr('secret-id');
|
||||
|
||||
// Retrieve from storage or prompt for private key
|
||||
var private_key = sessionStorage.getItem('private_key');
|
||||
if (!private_key) {
|
||||
$('#privkey_modal').modal('show');
|
||||
} else {
|
||||
unlock_secret(secret_id, private_key);
|
||||
}
|
||||
unlock_secret(secret_id);
|
||||
});
|
||||
|
||||
// Locking a secret
|
||||
$('button.lock-secret').click(function (event) {
|
||||
$('button.lock-secret').click(function() {
|
||||
var secret_id = $(this).attr('secret-id');
|
||||
lock_secret(secret_id);
|
||||
});
|
||||
|
||||
// Retrieve a session key
|
||||
$('#request_session_key').click(function() {
|
||||
var private_key_field = $('#user_privkey');
|
||||
var private_key = private_key_field.val();
|
||||
get_session_key(private_key);
|
||||
private_key_field.val("");
|
||||
});
|
||||
|
||||
// Retrieve a secret via the API
|
||||
function unlock_secret(secret_id) {
|
||||
$.ajax({
|
||||
url: netbox_api_path + 'secrets/secrets/' + secret_id + '/',
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function (response, status) {
|
||||
if (response.plaintext) {
|
||||
console.log("Secret retrieved successfully");
|
||||
$('#secret_' + secret_id).html(response.plaintext);
|
||||
$('button.unlock-secret[secret-id=' + secret_id + ']').hide();
|
||||
$('button.lock-secret[secret-id=' + secret_id + ']').show();
|
||||
} else {
|
||||
console.log("Secret was not decrypted. Prompt user for private key.");
|
||||
$('#privkey_modal').modal('show');
|
||||
}
|
||||
},
|
||||
error: function (xhr, ajaxOptions, thrownError) {
|
||||
console.log("Error: " + xhr.responseText);
|
||||
if (xhr.status == 403) {
|
||||
alert("Permission denied");
|
||||
} else {
|
||||
alert(xhr.responseText);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove secret data from the DOM
|
||||
function lock_secret(secret_id) {
|
||||
var secret_div = $('#secret_' + secret_id);
|
||||
|
||||
// Delete the plaintext
|
||||
secret_div.html('********');
|
||||
$(this).hide();
|
||||
$(this).siblings('button.unlock-secret').show();
|
||||
});
|
||||
$('button.lock-secret[secret-id=' + secret_id + ']').hide();
|
||||
$('button.unlock-secret[secret-id=' + secret_id + ']').show();
|
||||
}
|
||||
|
||||
// Adding/editing a secret
|
||||
private_key_field = $('#id_private_key');
|
||||
private_key_field.parents('form').submit(function(event) {
|
||||
console.log("form submitted");
|
||||
var private_key = sessionStorage.getItem('private_key');
|
||||
if (private_key) {
|
||||
private_key_field.val(private_key);
|
||||
} else if ($('form .requires-private-key:first').val()) {
|
||||
console.log("we need a key!");
|
||||
$('#privkey_modal').modal('show');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Saving a private RSA key locally
|
||||
$('#submit_privkey').click(function() {
|
||||
var private_key = $('#user_privkey').val();
|
||||
sessionStorage.setItem('private_key', private_key);
|
||||
});
|
||||
// Request a session key via the API
|
||||
function get_session_key(private_key) {
|
||||
var csrf_token = $('input[name=csrfmiddlewaretoken]').val();
|
||||
$.ajax({
|
||||
url: netbox_api_path + 'secrets/get-session-key/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
private_key: private_key
|
||||
},
|
||||
dataType: 'json',
|
||||
beforeSend: function(xhr, settings) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
},
|
||||
success: function (response, status) {
|
||||
console.log("Received a new session key");
|
||||
alert('Session key received! You may now unlock secrets.');
|
||||
},
|
||||
error: function (xhr, ajaxOptions, thrownError) {
|
||||
if (xhr.status == 403) {
|
||||
alert("Permission denied");
|
||||
} else {
|
||||
var json = jQuery.parseJSON(xhr.responseText);
|
||||
alert("Failed to retrieve a session key: " + json['error']);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a new public/private key pair via the API
|
||||
$('#generate_keypair').click(function() {
|
||||
$('#new_keypair_modal').modal('show');
|
||||
$.ajax({
|
||||
url: netbox_api_path + 'secrets/generate-keys/',
|
||||
url: netbox_api_path + 'secrets/generate-rsa-key-pair/',
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function (response, status) {
|
||||
@@ -63,41 +103,13 @@ $(document).ready(function() {
|
||||
});
|
||||
});
|
||||
|
||||
// Enter a newly generated public key
|
||||
// Accept a new RSA key pair generated via the API
|
||||
$('#use_new_pubkey').click(function() {
|
||||
var new_pubkey = $('#new_pubkey');
|
||||
|
||||
if (new_pubkey.val()) {
|
||||
$('#id_public_key').val(new_pubkey.val());
|
||||
}
|
||||
});
|
||||
|
||||
// Retrieve a secret via the API
|
||||
function unlock_secret(secret_id, private_key) {
|
||||
var csrf_token = $('input[name=csrfmiddlewaretoken]').val();
|
||||
$.ajax({
|
||||
url: netbox_api_path + 'secrets/secrets/' + secret_id + '/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
private_key: private_key
|
||||
},
|
||||
dataType: 'json',
|
||||
beforeSend: function(xhr, settings) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
},
|
||||
success: function (response, status) {
|
||||
$('#secret_' + secret_id).html(response.plaintext);
|
||||
$('button.unlock-secret[secret-id=' + secret_id + ']').hide();
|
||||
$('button.lock-secret[secret-id=' + secret_id + ']').show();
|
||||
},
|
||||
error: function (xhr, ajaxOptions, thrownError) {
|
||||
if (xhr.status == 403) {
|
||||
alert("Permission denied");
|
||||
} else {
|
||||
var json = jQuery.parseJSON(xhr.responseText);
|
||||
alert("Decryption failed: " + json['error']);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from dcim.models import Device
|
||||
from ipam.api.serializers import IPAddressNestedSerializer
|
||||
from dcim.api.serializers import NestedDeviceSerializer
|
||||
from secrets.models import Secret, SecretRole
|
||||
|
||||
|
||||
@@ -16,34 +16,41 @@ class SecretRoleSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class SecretRoleNestedSerializer(SecretRoleSerializer):
|
||||
class NestedSecretRoleSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
|
||||
|
||||
class Meta(SecretRoleSerializer.Meta):
|
||||
pass
|
||||
class Meta:
|
||||
model = SecretRole
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
#
|
||||
# Secrets
|
||||
#
|
||||
|
||||
class SecretDeviceSerializer(serializers.ModelSerializer):
|
||||
primary_ip = IPAddressNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['id', 'name', 'primary_ip']
|
||||
|
||||
|
||||
class SecretSerializer(serializers.ModelSerializer):
|
||||
device = SecretDeviceSerializer()
|
||||
role = SecretRoleNestedSerializer()
|
||||
device = NestedDeviceSerializer()
|
||||
role = NestedSecretRoleSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
|
||||
|
||||
|
||||
class SecretNestedSerializer(SecretSerializer):
|
||||
class WritableSecretSerializer(serializers.ModelSerializer):
|
||||
plaintext = serializers.CharField()
|
||||
|
||||
class Meta(SecretSerializer.Meta):
|
||||
fields = ['id', 'name']
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['id', 'device', 'role', 'name', 'plaintext']
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate uniqueness of name if one has been provided.
|
||||
if data.get('name', None):
|
||||
validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name'))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
|
||||
return data
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
from django.conf.urls import url
|
||||
from rest_framework import routers
|
||||
|
||||
from .views import *
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
class SecretsRootView(routers.APIRootView):
|
||||
"""
|
||||
Secrets API root view
|
||||
"""
|
||||
def get_view_name(self):
|
||||
return 'Secrets'
|
||||
|
||||
# Secrets
|
||||
url(r'^secrets/$', SecretListView.as_view(), name='secret_list'),
|
||||
url(r'^secrets/(?P<pk>\d+)/$', SecretDetailView.as_view(), name='secret_detail'),
|
||||
|
||||
# Secret roles
|
||||
url(r'^secret-roles/$', SecretRoleListView.as_view(), name='secretrole_list'),
|
||||
url(r'^secret-roles/(?P<pk>\d+)/$', SecretRoleDetailView.as_view(), name='secretrole_detail'),
|
||||
router = routers.DefaultRouter()
|
||||
router.APIRootView = SecretsRootView
|
||||
|
||||
# Miscellaneous
|
||||
url(r'^generate-keys/$', RSAKeyGeneratorView.as_view(), name='generate_keys'),
|
||||
# Secrets
|
||||
router.register(r'secret-roles', views.SecretRoleViewSet)
|
||||
router.register(r'secrets', views.SecretViewSet)
|
||||
|
||||
]
|
||||
# Miscellaneous
|
||||
router.register(r'get-session-key', views.GetSessionKeyViewSet, base_name='get-session-key')
|
||||
router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, base_name='generate-rsa-key-pair')
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -1,142 +1,202 @@
|
||||
import base64
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseBadRequest
|
||||
|
||||
from rest_framework import generics
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ViewSet
|
||||
|
||||
from extras.api.renderers import FormlessBrowsableAPIRenderer, FreeRADIUSClientsRenderer
|
||||
from secrets.exceptions import InvalidSessionKey
|
||||
from secrets.filters import SecretFilter
|
||||
from secrets.models import Secret, SecretRole, UserKey
|
||||
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
from utilities.api import WritableSerializerMixin
|
||||
from . import serializers
|
||||
|
||||
|
||||
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
||||
ERR_USERKEY_INACTIVE = "UserKey has not been activated for decryption."
|
||||
ERR_PRIVKEY_MISSING = "Private key was not provided."
|
||||
ERR_PRIVKEY_INVALID = "Invalid private key."
|
||||
|
||||
|
||||
class SecretRoleListView(generics.ListAPIView):
|
||||
"""
|
||||
List all secret roles
|
||||
"""
|
||||
#
|
||||
# Secret Roles
|
||||
#
|
||||
|
||||
class SecretRoleViewSet(ModelViewSet):
|
||||
queryset = SecretRole.objects.all()
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class SecretRoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single secret role
|
||||
"""
|
||||
queryset = SecretRole.objects.all()
|
||||
serializer_class = serializers.SecretRoleSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
#
|
||||
# Secrets
|
||||
#
|
||||
|
||||
|
||||
class SecretListView(generics.GenericAPIView):
|
||||
"""
|
||||
List secrets (filterable). If a private key is POSTed, attempt to decrypt each Secret.
|
||||
"""
|
||||
queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\
|
||||
.prefetch_related('role__users', 'role__groups')
|
||||
class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = Secret.objects.select_related(
|
||||
'device__primary_ip4', 'device__primary_ip6', 'role',
|
||||
).prefetch_related(
|
||||
'role__users', 'role__groups',
|
||||
)
|
||||
serializer_class = serializers.SecretSerializer
|
||||
write_serializer_class = serializers.WritableSecretSerializer
|
||||
filter_class = SecretFilter
|
||||
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, private_key=None):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
master_key = None
|
||||
|
||||
# Attempt to decrypt each Secret if a private key was provided.
|
||||
if private_key:
|
||||
def _get_encrypted_fields(self, serializer):
|
||||
"""
|
||||
Since we can't call encrypt() on the serializer like we can on the Secret model, we need to calculate the
|
||||
ciphertext and hash values by encrypting a dummy copy. These can be passed to the serializer's save() method.
|
||||
"""
|
||||
s = Secret(plaintext=serializer.validated_data['plaintext'])
|
||||
s.encrypt(self.master_key)
|
||||
return ({
|
||||
'ciphertext': s.ciphertext,
|
||||
'hash': s.hash,
|
||||
})
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
|
||||
super(SecretViewSet, self).initial(request, *args, **kwargs)
|
||||
|
||||
# Read session key from HTTP cookie or header if it has been provided. The session key must be provided in order
|
||||
# to encrypt/decrypt secrets.
|
||||
if 'session_key' in request.COOKIES:
|
||||
session_key = base64.b64decode(request.COOKIES['session_key'])
|
||||
elif 'HTTP_X_SESSION_KEY' in request.META:
|
||||
session_key = base64.b64decode(request.META['HTTP_X_SESSION_KEY'])
|
||||
else:
|
||||
session_key = None
|
||||
|
||||
# We can't encrypt secret plaintext without a session key.
|
||||
# assert False, self.action
|
||||
if self.action in ['create', 'update'] and session_key is None:
|
||||
raise ValidationError("A session key must be provided when creating or updating secrets.")
|
||||
|
||||
# Attempt to retrieve the master key for encryption/decryption if a session key has been provided.
|
||||
if session_key is not None:
|
||||
try:
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
return Response(
|
||||
{'error': ERR_USERKEY_MISSING},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if not uk.is_active():
|
||||
return Response(
|
||||
{'error': ERR_USERKEY_INACTIVE},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
master_key = uk.get_master_key(private_key)
|
||||
if master_key is not None:
|
||||
for s in queryset:
|
||||
if s.decryptable_by(request.user):
|
||||
s.decrypt(master_key)
|
||||
else:
|
||||
return Response(
|
||||
{'error': ERR_PRIVKEY_INVALID},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
sk = SessionKey.objects.get(userkey__user=request.user)
|
||||
self.master_key = sk.get_master_key(session_key)
|
||||
except (SessionKey.DoesNotExist, InvalidSessionKey):
|
||||
raise ValidationError("Invalid session key.")
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
|
||||
def post(self, request):
|
||||
return self.get(request, private_key=request.POST.get('private_key'))
|
||||
secret = self.get_object()
|
||||
|
||||
|
||||
class SecretDetailView(generics.GenericAPIView):
|
||||
"""
|
||||
Retrieve a single Secret. If a private key is POSTed, attempt to decrypt the Secret.
|
||||
"""
|
||||
queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\
|
||||
.prefetch_related('role__users', 'role__groups')
|
||||
serializer_class = serializers.SecretSerializer
|
||||
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request, pk, private_key=None):
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
|
||||
# Attempt to decrypt the Secret if a private key was provided.
|
||||
if private_key:
|
||||
try:
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
return Response(
|
||||
{'error': ERR_USERKEY_MISSING},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if not uk.is_active():
|
||||
return Response(
|
||||
{'error': ERR_USERKEY_INACTIVE},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if not secret.decryptable_by(request.user):
|
||||
raise PermissionDenied(detail="You do not have permission to decrypt this secret.")
|
||||
master_key = uk.get_master_key(private_key)
|
||||
if master_key is None:
|
||||
return Response(
|
||||
{'error': ERR_PRIVKEY_INVALID},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
secret.decrypt(master_key)
|
||||
# Attempt to decrypt the secret if the master key is known
|
||||
if self.master_key is not None:
|
||||
secret.decrypt(self.master_key)
|
||||
|
||||
serializer = self.get_serializer(secret)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request, pk):
|
||||
return self.get(request, pk, private_key=request.POST.get('private_key'))
|
||||
def list(self, request, *args, **kwargs):
|
||||
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
|
||||
# Attempt to decrypt all secrets if the master key is known
|
||||
if self.master_key is not None:
|
||||
secrets = []
|
||||
for secret in page:
|
||||
secret.decrypt(self.master_key)
|
||||
secrets.append(secret)
|
||||
serializer = self.get_serializer(secrets, many=True)
|
||||
else:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(**self._get_encrypted_fields(serializer))
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(**self._get_encrypted_fields(serializer))
|
||||
|
||||
|
||||
class RSAKeyGeneratorView(APIView):
|
||||
class GetSessionKeyViewSet(ViewSet):
|
||||
"""
|
||||
Generate a new RSA key pair for a user. Authenticated because it's a ripe avenue for DoS.
|
||||
Retrieve a temporary session key to use for encrypting and decrypting secrets via the API. The user's private RSA
|
||||
key is POSTed with the name `private_key`. An example:
|
||||
|
||||
curl -v -X POST -H "Authorization: Token <token>" -H "Accept: application/json; indent=4" \\
|
||||
--data-urlencode "private_key@<filename>" https://netbox/api/secrets/get-session-key/
|
||||
|
||||
This request will yield a base64-encoded session key to be included in an `X-Session-Key` header in future requests:
|
||||
|
||||
{
|
||||
"session_key": "+8t4SI6XikgVmB5+/urhozx9O5qCQANyOk1MNe6taRf="
|
||||
}
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
def create(self, request):
|
||||
|
||||
# Read private key
|
||||
private_key = request.POST.get('private_key', None)
|
||||
if private_key is None:
|
||||
return HttpResponseBadRequest(ERR_PRIVKEY_MISSING)
|
||||
|
||||
# Validate user key
|
||||
try:
|
||||
user_key = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
return HttpResponseBadRequest(ERR_USERKEY_MISSING)
|
||||
if not user_key.is_active():
|
||||
return HttpResponseBadRequest(ERR_USERKEY_INACTIVE)
|
||||
|
||||
# Validate private key
|
||||
master_key = user_key.get_master_key(private_key)
|
||||
if master_key is None:
|
||||
return HttpResponseBadRequest(ERR_PRIVKEY_INVALID)
|
||||
|
||||
# Delete the existing SessionKey for this user if one exists
|
||||
SessionKey.objects.filter(userkey__user=request.user).delete()
|
||||
|
||||
# Create a new SessionKey
|
||||
sk = SessionKey(userkey=user_key)
|
||||
sk.save(master_key=master_key)
|
||||
encoded_key = base64.b64encode(sk.key)
|
||||
# b64decode() returns a bytestring under Python 3
|
||||
if not isinstance(encoded_key, str):
|
||||
encoded_key = encoded_key.decode()
|
||||
|
||||
# Craft the response
|
||||
response = Response({
|
||||
'session_key': encoded_key,
|
||||
})
|
||||
|
||||
# If token authentication is not in use, assign the session key as a cookie
|
||||
if request.auth is None:
|
||||
response.set_cookie('session_key', value=encoded_key)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class GenerateRSAKeyPairViewSet(ViewSet):
|
||||
"""
|
||||
This endpoint can be used to generate a new RSA key pair. The keys are returned in PEM format.
|
||||
|
||||
{
|
||||
"public_key": "<public key>",
|
||||
"private_key": "<private key>"
|
||||
}
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def list(self, request):
|
||||
|
||||
# Determine what size key to generate
|
||||
key_size = request.GET.get('key_size', 2048)
|
||||
|
||||
5
netbox/secrets/exceptions.py
Normal file
5
netbox/secrets/exceptions.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class InvalidSessionKey(Exception):
|
||||
"""
|
||||
Raised when the a provided session key is invalid.
|
||||
"""
|
||||
pass
|
||||
@@ -24,11 +24,16 @@ class SecretFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='device',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (Name)',
|
||||
label='Device (name)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -47,9 +47,8 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
|
||||
#
|
||||
|
||||
class SecretForm(BootstrapMixin, forms.ModelForm):
|
||||
private_key = forms.CharField(required=False, widget=forms.HiddenInput())
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
|
||||
widget=forms.PasswordInput(attrs={'class': 'requires-private-key'}))
|
||||
widget=forms.PasswordInput())
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
|
||||
widget=forms.PasswordInput())
|
||||
|
||||
@@ -59,9 +58,6 @@ class SecretForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
|
||||
if self.cleaned_data['plaintext']:
|
||||
validate_rsa_key(self.cleaned_data['private_key'])
|
||||
|
||||
if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
|
||||
raise forms.ValidationError({
|
||||
'plaintext2': "The two given plaintext values do not match. Please check your input."
|
||||
@@ -86,8 +82,7 @@ class SecretFromCSVForm(forms.ModelForm):
|
||||
|
||||
|
||||
class SecretImportForm(BootstrapMixin, BulkImportForm):
|
||||
private_key = forms.CharField(widget=forms.HiddenInput())
|
||||
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
|
||||
csv = CSVDataField(csv_form=SecretFromCSVForm)
|
||||
|
||||
|
||||
class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
|
||||
39
netbox/secrets/migrations/0002_userkey_add_session_key.py
Normal file
39
netbox/secrets/migrations/0002_userkey_add_session_key.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.10.6 on 2017-03-14 17:19
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('secrets', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SessionKey',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('cipher', models.BinaryField(max_length=512)),
|
||||
('hash', models.CharField(editable=False, max_length=128)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['user__username'],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userkey',
|
||||
name='user',
|
||||
field=models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sessionkey',
|
||||
name='userkey',
|
||||
field=models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='session_key', to='secrets.UserKey'),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from Crypto.Cipher import AES, PKCS1_OAEP
|
||||
from Crypto.Cipher import AES, PKCS1_OAEP, XOR
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from django.conf import settings
|
||||
@@ -13,14 +13,17 @@ from django.utils.encoding import force_bytes, python_2_unicode_compatible
|
||||
from dcim.models import Device
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
|
||||
from .exceptions import InvalidSessionKey
|
||||
from .hashers import SecretValidationHasher
|
||||
|
||||
|
||||
def generate_master_key():
|
||||
def generate_random_key(bits=256):
|
||||
"""
|
||||
Generate a new 256-bit (32 bytes) AES key to be used for symmetric encryption of secrets.
|
||||
Generate a random encryption key. Sizes is given in bits and must be in increments of 32.
|
||||
"""
|
||||
return os.urandom(32)
|
||||
if bits % 32:
|
||||
raise Exception("Invalid key size ({}). Key sizes must be in increments of 32 bits.".format(bits))
|
||||
return os.urandom(int(bits / 8))
|
||||
|
||||
|
||||
def encrypt_master_key(master_key, public_key):
|
||||
@@ -41,6 +44,14 @@ def decrypt_master_key(master_key_cipher, private_key):
|
||||
return cipher.decrypt(master_key_cipher)
|
||||
|
||||
|
||||
def xor_keys(key_a, key_b):
|
||||
"""
|
||||
Return the binary XOR of two given keys.
|
||||
"""
|
||||
xor = XOR.new(key_a)
|
||||
return xor.encrypt(key_b)
|
||||
|
||||
|
||||
class UserKeyQuerySet(models.QuerySet):
|
||||
|
||||
def active(self):
|
||||
@@ -58,7 +69,7 @@ class UserKey(CreatedUpdatedModel):
|
||||
copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
|
||||
matching (private) decryption key.
|
||||
"""
|
||||
user = models.OneToOneField(User, related_name='user_key', verbose_name='User')
|
||||
user = models.OneToOneField(User, related_name='user_key', editable=False)
|
||||
public_key = models.TextField(verbose_name='RSA public key')
|
||||
master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False)
|
||||
|
||||
@@ -121,7 +132,7 @@ class UserKey(CreatedUpdatedModel):
|
||||
|
||||
# If no other active UserKeys exist, generate a new master key and use it to activate this UserKey.
|
||||
if self.is_filled() and not self.is_active() and not UserKey.objects.active().count():
|
||||
master_key = generate_master_key()
|
||||
master_key = generate_random_key()
|
||||
self.master_key_cipher = encrypt_master_key(master_key, self.public_key)
|
||||
|
||||
super(UserKey, self).save(*args, **kwargs)
|
||||
@@ -171,6 +182,53 @@ class UserKey(CreatedUpdatedModel):
|
||||
self.save()
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class SessionKey(models.Model):
|
||||
"""
|
||||
A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets.
|
||||
"""
|
||||
userkey = models.OneToOneField(UserKey, related_name='session_key', on_delete=models.CASCADE, editable=False)
|
||||
cipher = models.BinaryField(max_length=512, editable=False)
|
||||
hash = models.CharField(max_length=128, editable=False)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
key = None
|
||||
|
||||
class Meta:
|
||||
ordering = ['user__username']
|
||||
|
||||
def __str__(self):
|
||||
return self.userkey.user.username
|
||||
|
||||
def save(self, master_key=None, *args, **kwargs):
|
||||
|
||||
if master_key is None:
|
||||
raise Exception("The master key must be provided to save a session key.")
|
||||
|
||||
# Generate a random 256-bit session key if one is not already defined
|
||||
if self.key is None:
|
||||
self.key = generate_random_key()
|
||||
|
||||
# Generate SHA256 hash using Django's built-in password hashing mechanism
|
||||
self.hash = make_password(self.key)
|
||||
|
||||
# Encrypt master key using the session key
|
||||
self.cipher = xor_keys(self.key, master_key)
|
||||
|
||||
super(SessionKey, self).save(*args, **kwargs)
|
||||
|
||||
def get_master_key(self, session_key):
|
||||
|
||||
# Validate the provided session key
|
||||
if not check_password(session_key, self.hash):
|
||||
raise InvalidSessionKey()
|
||||
|
||||
# Decrypt master key using provided session key
|
||||
master_key = xor_keys(session_key, bytes(self.cipher))
|
||||
|
||||
return master_key
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class SecretRole(models.Model):
|
||||
"""
|
||||
@@ -303,9 +361,10 @@ class Secret(CreatedUpdatedModel):
|
||||
raise Exception("Must define ciphertext before unlocking.")
|
||||
|
||||
# Decrypt ciphertext and remove padding
|
||||
iv = self.ciphertext[0:16]
|
||||
iv = bytes(self.ciphertext[0:16])
|
||||
ciphertext = bytes(self.ciphertext[16:])
|
||||
aes = AES.new(secret_key, AES.MODE_CFB, iv)
|
||||
plaintext = self._unpad(aes.decrypt(self.ciphertext[16:]))
|
||||
plaintext = self._unpad(aes.decrypt(ciphertext))
|
||||
|
||||
# Verify decrypted plaintext against hash
|
||||
if not self.validate(plaintext):
|
||||
|
||||
228
netbox/secrets/tests/test_api.py
Normal file
228
netbox/secrets/tests/test_api.py
Normal file
@@ -0,0 +1,228 @@
|
||||
import base64
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
from users.models import Token
|
||||
from utilities.tests import HttpStatusMixin
|
||||
|
||||
|
||||
# Dummy RSA key pair for testing use only
|
||||
PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEA97wPWxpq5cClRu8Ssq609ZLfyx6E8ln/v/PdFZ7fxxmA4k+z
|
||||
1Q/Rn9/897PWy+1x2ZKlHjmaw1z7dS3PlGqdd453d1eY95xYVbFrIHs7yJy8lcDR
|
||||
2criwGEI68VP1FwcOkkwhicjtQZQS5fkkBIbRjA2wmt2PVT26YbOX2qCMItV1+me
|
||||
o/Ogh+uI1oNePJ8VYuGXbGNggf1qMY8fGhhhGY2b4PKuSTcsYjbg8adOGzFL9RXL
|
||||
I1X4PHNCzD/Y1vdM3jJXv+luk3TU+JIbzJeN5ZEEz+sIdlMPCAACaZAY/t9Kd/Lx
|
||||
Hr0o4K/6gqkZIukxFCK6sN53gibAXfaKc4xlqQIDAQABAoIBAQC4pDQVxNTTtQf6
|
||||
nImlH83EEto1++M+9pFFsi6fxLApJvsGsjzomke1Dy7uN93qVGk8rq3enzSYU58f
|
||||
sSs8BVKkH00vZ9ydAKxeAkREC1V9qkRsoTBHUY47sJcDkyZyssxfLNm7w0Q70h7a
|
||||
mLVEJBqr75eAxLN19vOpDk6Wkz3Bi0Dj27HLeme3hH5jLVQIIswWZnUDP3r/sdM/
|
||||
WA2GjoycPbug0r1FVZnxkFCrQ5yMfH3VzKBelj7356+5sc/TUXedDFN/DV2b90Ll
|
||||
+au7EEXecFYZwmX3SX2hpe6IWEpUW3B0fvm+Ipm8h7x68i7J0oi9EUXW2+UQYfOx
|
||||
dDLxTLvhAoGBAPtJJox4XcpzipSAzKxyV8K9ikUZCG2wJU7VHyZ5zpSXwl/husls
|
||||
brAzHQcnWayhxxuWeiQ6pLnRFPFXjlOH2FZqHXSLnfpDaymEksDPvo9GqRE3Q+F+
|
||||
lDRn72H1NLIj3Y3t5SwWRB34Dhy+gd5Ht9L3dCTH8cYvJGnmS4sH/z0NAoGBAPxh
|
||||
2rhS1B0S9mqqvpduUPxqUIWaztXaHC6ZikloOFcgVMdh9MRrpa2sa+bqcygyqrbH
|
||||
GZIIeGcWpmzeitWgSUNLMSIpdl/VoBSvZUMggdJyOHXayo/EhfFddGHdkfz0B0GW
|
||||
LzH8ow4JcYdhkTl4+xQstXJNVRJyw5ezFy35FHwNAoGAGZzjKP470R7lyS03r3wY
|
||||
Jelb5p8elM+XfemLO0i/HbY6QbuoZk9/GMac9tWz9jynJtC3smmn0KjXEaJzB2CZ
|
||||
VHWMewygFZo5mgnBS5XhPoldQjv310wnnw/Y/osXy/CL7KOK8Gt0lflqttNUOWvl
|
||||
+MLwO6+FnUXA2Gp42Lr/8SECgYANf2pEK2HewDHfmIwi6yp3pXPzAUmIlGanc1y6
|
||||
+lDxD/CYzTta+erdc/g9XFKWVsdciR9r+Pn/gW2bKve/3xer+qyBCDilfXZXRN4k
|
||||
jeuDhspQO0hUEg2b0AS2azQwlBiDQHX7tWg/CvBAbk5nBXpgJNf7aflfyDV/untF
|
||||
4SlgTQKBgGmcyU02lyM6ogGbzWqSsHgR1ZhYyTV9DekQx9GysLG1wT2QzgjxOw4K
|
||||
5PnVkOXr/ORqt+vJsYrtqBZQihmPPREKEwr2n8BRw0364z02wjvP04hDBHp4S5Ej
|
||||
PQeC5qErboVGMMpM2SamqGEfr+HJ/uRF6mEmm+xjI57aOvAwPW0B
|
||||
-----END RSA PRIVATE KEY-----"""
|
||||
|
||||
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA97wPWxpq5cClRu8Ssq60
|
||||
9ZLfyx6E8ln/v/PdFZ7fxxmA4k+z1Q/Rn9/897PWy+1x2ZKlHjmaw1z7dS3PlGqd
|
||||
d453d1eY95xYVbFrIHs7yJy8lcDR2criwGEI68VP1FwcOkkwhicjtQZQS5fkkBIb
|
||||
RjA2wmt2PVT26YbOX2qCMItV1+meo/Ogh+uI1oNePJ8VYuGXbGNggf1qMY8fGhhh
|
||||
GY2b4PKuSTcsYjbg8adOGzFL9RXLI1X4PHNCzD/Y1vdM3jJXv+luk3TU+JIbzJeN
|
||||
5ZEEz+sIdlMPCAACaZAY/t9Kd/LxHr0o4K/6gqkZIukxFCK6sN53gibAXfaKc4xl
|
||||
qQIDAQAB
|
||||
-----END PUBLIC KEY-----"""
|
||||
|
||||
|
||||
class SecretRoleTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
|
||||
|
||||
self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1')
|
||||
self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
|
||||
self.secretrole3 = SecretRole.objects.create(name='Test Secret Role 3', slug='test-secret-role-3')
|
||||
|
||||
def test_get_secretrole(self):
|
||||
|
||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.secretrole1.name)
|
||||
|
||||
def test_list_secretroles(self):
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_secretrole(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test SecretRole 4',
|
||||
'slug': 'test-secretrole-4',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(SecretRole.objects.count(), 4)
|
||||
secretrole4 = SecretRole.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(secretrole4.name, data['name'])
|
||||
self.assertEqual(secretrole4.slug, data['slug'])
|
||||
|
||||
def test_update_secretrole(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test SecretRole X',
|
||||
'slug': 'test-secretrole-x',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(SecretRole.objects.count(), 3)
|
||||
secretrole1 = SecretRole.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(secretrole1.name, data['name'])
|
||||
self.assertEqual(secretrole1.slug, data['slug'])
|
||||
|
||||
def test_delete_secretrole(self):
|
||||
|
||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(SecretRole.objects.count(), 2)
|
||||
|
||||
|
||||
class SecretTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = User.objects.create(username='testuser', is_superuser=True)
|
||||
token = Token.objects.create(user=user)
|
||||
|
||||
userkey = UserKey(user=user, public_key=PUBLIC_KEY)
|
||||
userkey.save()
|
||||
self.master_key = userkey.get_master_key(PRIVATE_KEY)
|
||||
session_key = SessionKey(userkey=userkey)
|
||||
session_key.save(self.master_key)
|
||||
|
||||
self.header = {
|
||||
'HTTP_AUTHORIZATION': 'Token {}'.format(token.key),
|
||||
'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key),
|
||||
}
|
||||
|
||||
self.plaintext = {
|
||||
'secret1': 'Secret#1Plaintext',
|
||||
'secret2': 'Secret#2Plaintext',
|
||||
'secret3': 'Secret#3Plaintext',
|
||||
}
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device Type 1')
|
||||
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1')
|
||||
self.device = Device.objects.create(
|
||||
name='Test Device 1', site=site, device_type=devicetype, device_role=devicerole
|
||||
)
|
||||
self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1')
|
||||
self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
|
||||
self.secret1 = Secret(
|
||||
device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1']
|
||||
)
|
||||
self.secret1.encrypt(self.master_key)
|
||||
self.secret1.save()
|
||||
self.secret2 = Secret(
|
||||
device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2']
|
||||
)
|
||||
self.secret2.encrypt(self.master_key)
|
||||
self.secret2.save()
|
||||
self.secret3 = Secret(
|
||||
device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3']
|
||||
)
|
||||
self.secret3.encrypt(self.master_key)
|
||||
self.secret3.save()
|
||||
|
||||
def test_get_secret(self):
|
||||
|
||||
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['plaintext'], self.plaintext['secret1'])
|
||||
|
||||
def test_list_secrets(self):
|
||||
|
||||
url = reverse('secrets-api:secret-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_secret(self):
|
||||
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'plaintext': 'Secret#4Plaintext',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secret-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
||||
self.assertEqual(Secret.objects.count(), 4)
|
||||
secret4 = Secret.objects.get(pk=response.data['id'])
|
||||
secret4.decrypt(self.master_key)
|
||||
self.assertEqual(secret4.role_id, data['role'])
|
||||
self.assertEqual(secret4.plaintext, data['plaintext'])
|
||||
|
||||
def test_update_secret(self):
|
||||
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole2.pk,
|
||||
'plaintext': 'NewPlaintext',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
||||
self.assertEqual(Secret.objects.count(), 3)
|
||||
secret1 = Secret.objects.get(pk=response.data['id'])
|
||||
secret1.decrypt(self.master_key)
|
||||
self.assertEqual(secret1.role_id, data['role'])
|
||||
self.assertEqual(secret1.plaintext, data['plaintext'])
|
||||
|
||||
def test_delete_secret(self):
|
||||
|
||||
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Secret.objects.count(), 2)
|
||||
@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from secrets.models import UserKey, Secret, generate_master_key, encrypt_master_key, decrypt_master_key
|
||||
from secrets.models import UserKey, Secret, encrypt_master_key, decrypt_master_key, generate_random_key
|
||||
from secrets.hashers import SecretValidationHasher
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class UserKeyTestCase(TestCase):
|
||||
"""
|
||||
Validate the activation of a UserKey.
|
||||
"""
|
||||
master_key = generate_master_key()
|
||||
master_key = generate_random_key()
|
||||
alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public'])
|
||||
self.assertFalse(alice_uk.is_active(), "Inactive UserKey is_active() did not return False")
|
||||
alice_uk.activate(master_key)
|
||||
@@ -62,7 +62,7 @@ class UserKeyTestCase(TestCase):
|
||||
"""
|
||||
Test the decryption of a master key using the user's private key.
|
||||
"""
|
||||
master_key = generate_master_key()
|
||||
master_key = generate_random_key()
|
||||
alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public'])
|
||||
alice_uk.activate(master_key)
|
||||
retrieved_master_key = alice_uk.get_master_key(self.TEST_KEYS['alice_private'])
|
||||
@@ -72,7 +72,7 @@ class UserKeyTestCase(TestCase):
|
||||
"""
|
||||
Ensure that an exception is raised when attempting to retrieve a secret key using an invalid private key.
|
||||
"""
|
||||
secret_key = generate_master_key()
|
||||
secret_key = generate_random_key()
|
||||
secret_key_cipher = encrypt_master_key(secret_key, self.TEST_KEYS['alice_public'])
|
||||
try:
|
||||
decrypted_secret_key = decrypt_master_key(secret_key_cipher, self.TEST_KEYS['bob_private'])
|
||||
@@ -88,7 +88,7 @@ class SecretTestCase(TestCase):
|
||||
Test basic encryption and decryption functionality using a random master key.
|
||||
"""
|
||||
plaintext = "FooBar123"
|
||||
secret_key = generate_master_key()
|
||||
secret_key = generate_random_key()
|
||||
s = Secret(plaintext=plaintext)
|
||||
s.encrypt(secret_key)
|
||||
|
||||
@@ -118,7 +118,7 @@ class SecretTestCase(TestCase):
|
||||
Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
|
||||
"""
|
||||
plaintext = "1234567890abcdef"
|
||||
secret_key = generate_master_key()
|
||||
secret_key = generate_random_key()
|
||||
ivs = []
|
||||
ciphertexts = []
|
||||
for i in range(1, 51):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import base64
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import permission_required, login_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
@@ -12,7 +14,7 @@ from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, Obje
|
||||
|
||||
from . import filters, forms, tables
|
||||
from .decorators import userkey_required
|
||||
from .models import SecretRole, Secret, UserKey
|
||||
from .models import SecretRole, Secret, SessionKey, UserKey
|
||||
|
||||
|
||||
#
|
||||
@@ -30,7 +32,7 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
model = SecretRole
|
||||
form_class = forms.SecretRoleForm
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
def get_return_url(self, obj):
|
||||
return reverse('secrets:secretrole_list')
|
||||
|
||||
|
||||
@@ -77,23 +79,30 @@ def secret_add(request, pk):
|
||||
form = forms.SecretForm(request.POST, instance=secret)
|
||||
if form.is_valid():
|
||||
|
||||
# Retrieve the master key from the current user's UserKey
|
||||
master_key = uk.get_master_key(form.cleaned_data['private_key'])
|
||||
if master_key is None:
|
||||
form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
|
||||
# We need a valid session key in order to create a Secret
|
||||
session_key = base64.b64decode(request.COOKIES.get('session_key', None))
|
||||
if session_key is None:
|
||||
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
|
||||
|
||||
# Create and encrypt the new Secret
|
||||
else:
|
||||
secret = form.save(commit=False)
|
||||
secret.plaintext = str(form.cleaned_data['plaintext'])
|
||||
secret.encrypt(master_key)
|
||||
secret.save()
|
||||
master_key = None
|
||||
try:
|
||||
sk = SessionKey.objects.get(userkey__user=request.user)
|
||||
master_key = sk.get_master_key(session_key)
|
||||
except SessionKey.DoesNotExist:
|
||||
form.add_error(None, "No session key found for this user.")
|
||||
|
||||
messages.success(request, u"Added new secret: {}.".format(secret))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('dcim:device_addsecret', pk=device.pk)
|
||||
else:
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
if master_key is not None:
|
||||
secret = form.save(commit=False)
|
||||
secret.plaintext = str(form.cleaned_data['plaintext'])
|
||||
secret.encrypt(master_key)
|
||||
secret.save()
|
||||
messages.success(request, u"Added new secret: {}.".format(secret))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('dcim:device_addsecret', pk=device.pk)
|
||||
else:
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
|
||||
else:
|
||||
form = forms.SecretForm(instance=secret)
|
||||
@@ -110,32 +119,43 @@ def secret_add(request, pk):
|
||||
def secret_edit(request, pk):
|
||||
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = forms.SecretForm(request.POST, instance=secret)
|
||||
if form.is_valid():
|
||||
|
||||
# Re-encrypt the Secret if a plaintext has been specified.
|
||||
if form.cleaned_data['plaintext']:
|
||||
# Re-encrypt the Secret if a plaintext and session key have been provided.
|
||||
session_key = base64.b64decode(request.COOKIES.get('session_key', None))
|
||||
if form.cleaned_data['plaintext'] and session_key is not None:
|
||||
|
||||
# Retrieve the master key from the current user's UserKey
|
||||
master_key = uk.get_master_key(form.cleaned_data['private_key'])
|
||||
if master_key is None:
|
||||
form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
|
||||
# Retrieve the master key using the provided session key
|
||||
master_key = None
|
||||
try:
|
||||
sk = SessionKey.objects.get(userkey__user=request.user)
|
||||
master_key = sk.get_master_key(session_key)
|
||||
except SessionKey.DoesNotExist:
|
||||
form.add_error(None, "No session key found for this user.")
|
||||
|
||||
# Create and encrypt the new Secret
|
||||
else:
|
||||
if master_key is not None:
|
||||
secret = form.save(commit=False)
|
||||
secret.plaintext = str(form.cleaned_data['plaintext'])
|
||||
secret.encrypt(master_key)
|
||||
secret.save()
|
||||
messages.success(request, u"Modified secret {}.".format(secret))
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
else:
|
||||
form.add_error(None, "Invalid session key. Unable to encrypt secret data.")
|
||||
|
||||
# We can't save the plaintext without a session key.
|
||||
elif form.cleaned_data['plaintext']:
|
||||
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
|
||||
|
||||
# If no new plaintext was specified, a session key is not needed.
|
||||
else:
|
||||
secret = form.save()
|
||||
|
||||
messages.success(request, u"Modified secret {}.".format(secret))
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
messages.success(request, u"Modified secret {}.".format(secret))
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
|
||||
else:
|
||||
form = forms.SecretForm(instance=secret)
|
||||
@@ -157,19 +177,28 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
@userkey_required()
|
||||
def secret_import(request):
|
||||
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
session_key = request.COOKIES.get('session_key', None)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = forms.SecretImportForm(request.POST)
|
||||
|
||||
if session_key is None:
|
||||
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
new_secrets = []
|
||||
|
||||
# Retrieve the master key from the current user's UserKey
|
||||
master_key = uk.get_master_key(form.cleaned_data['private_key'])
|
||||
session_key = base64.b64decode(session_key)
|
||||
master_key = None
|
||||
try:
|
||||
sk = SessionKey.objects.get(userkey__user=request.user)
|
||||
master_key = sk.get_master_key(session_key)
|
||||
except SessionKey.DoesNotExist:
|
||||
form.add_error(None, "No session key found for this user.")
|
||||
|
||||
if master_key is None:
|
||||
form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
|
||||
|
||||
else:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>NetBox - {% block title %}Home{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-3.3.6-dist/css/bootstrap.min.css' %}">
|
||||
<title>NetBox - {% block title %}Home{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{% static 'bootstrap-3.3.6-dist/css/bootstrap.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'font-awesome-4.6.3/css/font-awesome.min.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/base.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/base.css' %}">
|
||||
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
|
||||
</head>
|
||||
@@ -28,7 +28,7 @@
|
||||
<div id="navbar" class="navbar-collapse collapse">
|
||||
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/sites/' or 'tenancy' in request.path %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:site_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Sites</a></li>
|
||||
@@ -54,7 +54,7 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/rack' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:rack_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Racks</a></li>
|
||||
@@ -72,11 +72,9 @@
|
||||
{% if perms.dcim.add_rackrole %}
|
||||
<li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li>
|
||||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li><a href="{% url 'dcim:rackreservation_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Reservations</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/device' or request.path|startswith:'/dcim/manufacturers/' or request.path|startswith:'/dcim/platforms/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:device_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Devices</a></li>
|
||||
@@ -112,7 +110,7 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/console-connections/,/dcim/power-connections/,/dcim/interface-connections/' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/dcim/console-connections/' or request.path|startswith:'/dcim/power-connections/' or request.path|startswith:'/dcim/interface-connections/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Connections <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'dcim:console_connections_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Console Connections</a></li>
|
||||
@@ -135,7 +133,7 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/ipam/' and not request.path|contains:'/ipam/vlan' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlan' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Space <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'ipam:ipaddress_list' %}"><i class="fa fa-search" aria-hidden="true"></i> IP Addresses</a></li>
|
||||
@@ -181,7 +179,7 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/ipam/vlan' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/ipam/vlan' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'ipam:vlan_list' %}"><i class="fa fa-search" aria-hidden="true"></i> VLANs</a></li>
|
||||
@@ -201,7 +199,7 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/circuits/' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'circuits:provider_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Providers</a></li>
|
||||
@@ -225,7 +223,7 @@
|
||||
</ul>
|
||||
</li>
|
||||
{% if request.user.is_authenticated %}
|
||||
<li class="dropdown{% if request.path|contains:'/secrets/' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|startswith:'/secrets/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Secrets <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="{% url 'secrets:secret_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Secrets</a></li>
|
||||
@@ -256,10 +254,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container wrapper">
|
||||
<div class="container wrapper">
|
||||
{% if settings.BANNER_TOP %}
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
{{ settings.BANNER_TOP|safe }}
|
||||
{{ settings.BANNER_TOP|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if settings.MAINTENANCE_MODE %}
|
||||
@@ -268,24 +266,24 @@
|
||||
<p>NetBox is currently in maintenance mode. Functionality may be limited.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissable" role="alert">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissable" role="alert">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{{ message|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
<div class="push"></div>
|
||||
{% if settings.BANNER_BOTTOM %}
|
||||
<div class="alert alert-info text-center banner-bottom" role="alert">
|
||||
{% if settings.BANNER_BOTTOM %}
|
||||
<div class="alert alert-info text-center banner-bottom" role="alert">
|
||||
{{ settings.BANNER_BOTTOM|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-xs-4">
|
||||
<p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
|
||||
@@ -296,14 +294,14 @@
|
||||
<div class="col-xs-4 text-right">
|
||||
<p class="text-muted">
|
||||
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> ·
|
||||
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'api_docs' %}">API</a> ·
|
||||
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> ·
|
||||
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> ·
|
||||
<i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="text/javascript">
|
||||
var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
|
||||
</script>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if show_graphs %}
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider_graphs' pk=provider.pk %}" title="Show graphs">
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
|
||||
<i class="fa fa-signal" aria-hidden="true"></i>
|
||||
Graphs
|
||||
</button>
|
||||
|
||||
@@ -194,6 +194,35 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>IP Addresses</strong>
|
||||
</div>
|
||||
{% if ip_addresses %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for ip in ip_addresses %}
|
||||
{% include 'dcim/inc/ipaddress.html' %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% elif interfaces or mgmt_interfaces %}
|
||||
<div class="panel-body text-muted">
|
||||
None assigned
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="panel-body">
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}">Create an interface</a> to assign an IP.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
{% if interfaces or mgmt_interfaces %}
|
||||
<div class="panel-footer text-right">
|
||||
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign IP address
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Services</strong>
|
||||
@@ -221,7 +250,7 @@
|
||||
<div class="panel-heading">
|
||||
<strong>Critical Connections</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
<table class="table table-hover panel-body">
|
||||
{% for iface in mgmt_interfaces %}
|
||||
{% include 'dcim/inc/interface.html' with icon='wrench' %}
|
||||
{% empty %}
|
||||
@@ -346,7 +375,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
<table class="table table-hover panel-body">
|
||||
{% for devicebay in device_bays %}
|
||||
{% include 'dcim/inc/devicebay.html' with selectable=True %}
|
||||
{% empty %}
|
||||
@@ -387,9 +416,6 @@
|
||||
<div class="panel-heading">
|
||||
<strong>Interfaces</strong>
|
||||
<div class="pull-right">
|
||||
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||
</button>
|
||||
{% if perms.dcim.change_interface and interfaces|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
@@ -402,7 +428,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover panel-body component-list">
|
||||
<table class="table table-hover panel-body">
|
||||
{% for iface in interfaces %}
|
||||
{% include 'dcim/inc/interface.html' with selectable=True %}
|
||||
{% empty %}
|
||||
@@ -459,7 +485,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
<table class="table table-hover panel-body">
|
||||
{% for csp in cs_ports %}
|
||||
{% include 'dcim/inc/consoleserverport.html' with selectable=True %}
|
||||
{% empty %}
|
||||
@@ -511,7 +537,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
<table class="table table-hover panel-body">
|
||||
{% for po in power_outlets %}
|
||||
{% include 'dcim/inc/poweroutlet.html' with selectable=True %}
|
||||
{% empty %}
|
||||
@@ -602,18 +628,6 @@ $(".powerport-toggle").click(function() {
|
||||
$(".interface-toggle").click(function() {
|
||||
return toggleConnection($(this), "dcim/interface-connections/");
|
||||
});
|
||||
// Toggle the display of IP addresses under interfaces
|
||||
$('button.toggle-ips').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
if (selected) {
|
||||
$('#interfaces_table tr.ipaddress').hide();
|
||||
} else {
|
||||
$('#interfaces_table tr.ipaddress').show();
|
||||
}
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
<script src="{% static 'js/graphs.js' %}"></script>
|
||||
<script src="{% static 'js/secrets.js' %}"></script>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rack</td>
|
||||
<td>Rack name (optional)</td>
|
||||
<td>Rack name</td>
|
||||
<td>R101</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<table class="table table-hover table-condensed panel-body" id="hardware">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module</th>
|
||||
<th>Name</th>
|
||||
<th></th>
|
||||
<th>Manufacturer</th>
|
||||
<th>Part Number</th>
|
||||
@@ -55,81 +55,18 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in modules %}
|
||||
<tr>
|
||||
<td>{{ m.name }}</td>
|
||||
<td>{% if not m.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ m.manufacturer|default:'' }}</td>
|
||||
<td>{{ m.part_id }}</td>
|
||||
<td>{{ m.serial }}</td>
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_module %}
|
||||
<a href="{% url 'dcim:module_edit' pk=m.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_module %}
|
||||
<a href="{% url 'dcim:module_delete' pk=m.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% for m2 in m.submodules.all %}
|
||||
<tr>
|
||||
<td style="padding-left: 20px">{{ m2.name }}</td>
|
||||
<td>{% if not m2.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ m2.manufacturer|default:'' }}</td>
|
||||
<td>{{ m2.part_id }}</td>
|
||||
<td>{{ m2.serial }}</td>
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_module %}
|
||||
<a href="{% url 'dcim:module_edit' pk=m2.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_module %}
|
||||
<a href="{% url 'dcim:module_delete' pk=m2.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% for m3 in m2.submodules.all %}
|
||||
<tr>
|
||||
<td style="padding-left: 40px">{{ m3.name }}</td>
|
||||
<td>{% if not m3.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ m3.manufacturer|default:'' }}</td>
|
||||
<td>{{ m3.part_id }}</td>
|
||||
<td>{{ m3.serial }}</td>
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_module %}
|
||||
<a href="{% url 'dcim:module_edit' pk=m3.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_module %}
|
||||
<a href="{% url 'dcim:module_delete' pk=m3.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% for m4 in m3.submodules.all %}
|
||||
<tr>
|
||||
<td style="padding-left: 60px">{{ m4.name }}</td>
|
||||
<td>{% if not m4.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ m4.manufacturer|default:'' }}</td>
|
||||
<td>{{ m4.part_id }}</td>
|
||||
<td>{{ m4.serial }}</td>
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_module %}
|
||||
<a href="{% url 'dcim:module_edit' pk=m4.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_module %}
|
||||
<a href="{% url 'dcim:module_delete' pk=m4.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% for item in inventory_items %}
|
||||
{% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
|
||||
{% include template_name %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if perms.dcim.add_module %}
|
||||
<a href="{% url 'dcim:module_add' device=device.pk %}" class="btn btn-success">
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-success">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a Module
|
||||
Add Inventory Item
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$.ajax({
|
||||
url: "{% url 'dcim-api:device_lldp-neighbors' pk=device.pk %}",
|
||||
url: "{% url 'dcim-api:device-lldp-neighbors' pk=device.pk %}",
|
||||
dataType: 'json',
|
||||
success: function(json) {
|
||||
$.each(json, function(i, neighbor) {
|
||||
@@ -66,6 +66,9 @@ $(document).ready(function() {
|
||||
row.addClass('danger');
|
||||
}
|
||||
});
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert(xhr.responseText);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<tr class="consoleport{% if cp.cs_port and not cp.connection_status %} info{% endif %}">
|
||||
<tr{% if cp.cs_port and not cp.connection_status %} class="info"{% endif %}>
|
||||
{% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ cp.pk }}" />
|
||||
@@ -7,6 +7,7 @@
|
||||
<td>
|
||||
<i class="fa fa-fw fa-keyboard-o"></i> {{ cp.name }}
|
||||
</td>
|
||||
<td></td>
|
||||
{% if cp.cs_port %}
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=cp.cs_port.device.pk %}">{{ cp.cs_port.device }}</a>
|
||||
@@ -19,7 +20,7 @@
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td colspan="2" class="text-right">
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_consoleport %}
|
||||
{% if cp.cs_port %}
|
||||
{% if cp.connection_status %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<tr class="consoleserverport{% if csp.connected_console and not csp.connected_console.connection_status %} info{% endif %}">
|
||||
<tr{% if csp.connected_console and not csp.connected_console.connection_status %} class="info"{% endif %}>
|
||||
{% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ csp.pk }}" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td colspan="2" class="text-right">
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_consoleserverport %}
|
||||
{% if csp.connected_console %}
|
||||
{% if csp.connected_console.connection_status %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<tr class="devicebay">
|
||||
<tr>
|
||||
{% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<span class="text-muted">Vacant</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td colspan="2" class="text-right">
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_devicebay %}
|
||||
{% if devicebay.installed_device %}
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<tr class="interface{% if iface.connection and not iface.connection.connection_status %} info{% endif %}">
|
||||
<tr{% if iface.connection and not iface.connection.connection_status %} class="info"{% endif %}>
|
||||
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
|
||||
@@ -16,9 +16,10 @@
|
||||
<br /><small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if iface.is_lag %}
|
||||
<td colspan="2" class="text-muted">LAG interface</td>
|
||||
{% elif iface.is_virtual %}
|
||||
<td>
|
||||
<small>{{ iface.mac_address|default:'' }}</small>
|
||||
</td>
|
||||
{% if iface.is_virtual %}
|
||||
<td colspan="2" class="text-muted">Virtual interface</td>
|
||||
{% elif iface.connection %}
|
||||
{% with iface.connected_interface as connected_iface %}
|
||||
@@ -34,13 +35,7 @@
|
||||
<td colspan="2">
|
||||
<i class="fa fa-fw fa-globe" title="Circuit"></i>
|
||||
{% if peer_termination %}
|
||||
{% if peer_termination.interface %}
|
||||
<a href="{% url 'dcim:device' pk=peer_termination.interface.device.pk %}">{{ peer_termination.interface.device }}</a>
|
||||
(<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a>)
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a>
|
||||
{% endif %}
|
||||
via
|
||||
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a> via
|
||||
{% endif %}
|
||||
<a href="{% url 'circuits:circuit' pk=iface.circuit_termination.circuit_id %}">{{ iface.circuit_termination.circuit }}</a>
|
||||
</td>
|
||||
@@ -50,19 +45,14 @@
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td colspan="2" class="text-right">
|
||||
<td class="text-right">
|
||||
{% if show_graphs %}
|
||||
{% if iface.circuit_termination or iface.connection %}
|
||||
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface_graphs' pk=iface.pk %}" title="Show graphs">
|
||||
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs">
|
||||
<i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?interface_site={{ device.site.pk }}&interface_rack={{ device.rack.pk }}&interface_device={{ device.pk }}&interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.change_interface %}
|
||||
{% if not iface.is_virtual %}
|
||||
{% if iface.connection %}
|
||||
@@ -75,19 +65,19 @@
|
||||
<i class="fa fa-plug" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Disconnect">
|
||||
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
|
||||
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
|
||||
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
|
||||
<button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
|
||||
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
|
||||
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
|
||||
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
|
||||
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
|
||||
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -108,41 +98,3 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% for ip in iface.ip_addresses.all %}
|
||||
<tr class="ipaddress">
|
||||
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td colspan="2">
|
||||
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
|
||||
{% if ip.description %}
|
||||
<i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
|
||||
{% endif %}
|
||||
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
|
||||
<span class="label label-success">Primary</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if ip.vrf %}
|
||||
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">Global</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if perms.ipam.edit_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.ipam.delete_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
20
netbox/templates/dcim/inc/inventoryitem.html
Normal file
20
netbox/templates/dcim/inc/inventoryitem.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<tr>
|
||||
<td style="padding-left: {{ indent|add:5 }}px">{{ item.name }}</td>
|
||||
<td>{% if not item.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
|
||||
<td>{{ item.manufacturer|default:'' }}</td>
|
||||
<td>{{ item.part_id }}</td>
|
||||
<td>{{ item.serial }}</td>
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_inventory_item %}
|
||||
<a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_inventory_item %}
|
||||
<a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-xs btn-danger"><span class="glyphicon glyphicon-trash" aria-hidden="true"></span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% for item in item.child_items.all %}
|
||||
{% with template_name='dcim/inc/inventoryitem.html' indent=indent|add:20 %}
|
||||
{% include template_name %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
21
netbox/templates/dcim/inc/ipaddress.html
Normal file
21
netbox/templates/dcim/inc/ipaddress.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ ip.vrf|default:"Global" }}
|
||||
</td>
|
||||
<td>{{ ip.interface }}</td>
|
||||
<td>
|
||||
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
|
||||
<span class="label label-success">Primary</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if perms.ipam.delete_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1,4 +1,4 @@
|
||||
<tr class="poweroutlet{% if po.connected_port and not po.connected_port.connection_status %} info{% endif %}">
|
||||
<tr{% if po.connected_port and not po.connected_port.connection_status %} class="info"{% endif %}>
|
||||
{% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ po.pk }}" />
|
||||
@@ -19,7 +19,7 @@
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td colspan="2" class="text-right">
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_poweroutlet %}
|
||||
{% if po.connected_port %}
|
||||
{% if po.connected_port.connection_status %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<tr class="powerport{% if pp.power_outlet and not pp.connection_status %} info{% endif %}">
|
||||
<tr{% if pp.power_outlet and not pp.connection_status %} class="info"{% endif %}>
|
||||
{% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
|
||||
<td class="pk">
|
||||
<input name="pk" type="checkbox" value="{{ pp.pk }}" />
|
||||
@@ -7,6 +7,7 @@
|
||||
<td>
|
||||
<i class="fa fa-fw fa-bolt"></i> {{ pp.name }}
|
||||
</td>
|
||||
<td></td>
|
||||
{% if pp.power_outlet %}
|
||||
<td>
|
||||
<a href="{% url 'dcim:device' pk=pp.power_outlet.device.pk %}">{{ pp.power_outlet.device }}</a>
|
||||
@@ -19,7 +20,7 @@
|
||||
<span class="text-muted">Not connected</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td colspan="2" class="text-right">
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_powerport %}
|
||||
{% if pp.power_outlet %}
|
||||
{% if pp.connection_status %}
|
||||
|
||||
8
netbox/templates/dcim/inventoryitem_delete.html
Normal file
8
netbox/templates/dcim/inventoryitem_delete.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete inventory item {{ inventoryitem }}?{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>Are you sure you want to delete this inventory item from <strong>{{ inventoryitem.device }}</strong>?</p>
|
||||
{% endblock %}
|
||||
@@ -43,14 +43,12 @@
|
||||
{% render_field form.set_as_primary %}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Delete module {{ module }}?{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>Are you sure you want to delete this module from <strong>{{ module.device }}</strong>?</p>
|
||||
{% endblock %}
|
||||
@@ -210,18 +210,18 @@
|
||||
</tr>
|
||||
{% for resv in reservations %}
|
||||
<tr>
|
||||
<td>{{ resv.unit_list }}</td>
|
||||
<td>{{ resv.units|join:', ' }}</td>
|
||||
<td>
|
||||
{{ resv.description }}<br />
|
||||
<small>{{ resv.user }} · {{ resv.created }}</small>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
{% if perms.change_rackreservation %}
|
||||
<a href="{% url 'dcim:rackreservation_edit' pk=resv.pk %}" class="btn btn-warning btn-xs" title="Edit reservation">
|
||||
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_rackreservation %}
|
||||
{% if perms.delete_rackreservation %}
|
||||
<a href="{% url 'dcim:rackreservation_delete' pk=resv.pk %}" class="btn btn-danger btn-xs" title="Delete reservation">
|
||||
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% block title %}Rack Reservations{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackreservation_bulk_delete' %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if show_graphs %}
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site_graphs' pk=site.pk %}" title="Show graphs">
|
||||
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site-graphs' pk=site.pk %}" title="Show graphs">
|
||||
<i class="fa fa-signal" aria-hidden="true"></i>
|
||||
Graphs
|
||||
</button>
|
||||
|
||||
@@ -98,8 +98,14 @@
|
||||
<td>
|
||||
{% if ipaddress.interface %}
|
||||
<span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
|
||||
{% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_remove' pk=ipaddress.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_assign' pk=ipaddress.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -10,21 +10,13 @@
|
||||
|
||||
{% block form %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>IP Addresses</strong></div>
|
||||
<div class="panel-heading"><strong>IP Address</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.address_pattern %}
|
||||
{% render_field form.address %}
|
||||
{% render_field form.vrf %}
|
||||
{% render_field form.tenant %}
|
||||
{% render_field form.status %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,20 +16,39 @@
|
||||
{% render_field form.vrf %}
|
||||
{% render_field form.tenant %}
|
||||
{% render_field form.status %}
|
||||
{% if obj.pk %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Device</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
{% if obj.interface %}
|
||||
<a href="{% url 'dcim:device' pk=obj.interface.device.pk %}">{{ obj.interface.device }}</a>
|
||||
<a href="{% url 'ipam:ipaddress_remove' pk=obj.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% if obj.pk %}
|
||||
<a href="{% url 'ipam:ipaddress_assign' pk=obj.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">Interface</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">
|
||||
{% if obj.interface %}
|
||||
{{ obj.interface }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Interface Assignment</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.interface_site %}
|
||||
{% render_field form.interface_rack %}
|
||||
{% render_field form.interface_device %}
|
||||
{% render_field form.interface %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
|
||||
<div class="panel-body">
|
||||
|
||||
@@ -10,16 +10,15 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Your private RSA key is needed to complete this action. Once you've provided your key, it will
|
||||
remain cached locally until you close this browser tab.
|
||||
You do not have an active session key. To request one, please provide your private RSA key below.
|
||||
Once retrieved, your session key will be saved for future requests.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" id="user_privkey" style="height: 300px;"></textarea>
|
||||
</div>
|
||||
<div class="form-group text-right">
|
||||
<button id="submit_privkey" class="btn btn-primary unlock-secret" data-dismiss="modal">
|
||||
<i class="fa fa-save" aria-hidden="True"></i>
|
||||
Save RSA Key
|
||||
<button id="request_session_key" class="btn btn-primary" data-dismiss="modal">
|
||||
Request session key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,21 @@
|
||||
<div class="row">
|
||||
<div class="col-sm-3 col-md-2 col-md-offset-2">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li{% ifequal active_tab "profile" %} class="active"{% endifequal %}><a href="{% url 'user:profile' %}">Profile</a></li>
|
||||
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}><a href="{% url 'user:change_password' %}">Change Password</a></li>
|
||||
<li{% ifequal active_tab "userkey" %} class="active"{% endifequal %}><a href="{% url 'user:userkey' %}">User Key</a></li>
|
||||
<li{% ifequal active_tab "recent_activity" %} class="active"{% endifequal %}><a href="{% url 'user:recent_activity' %}">Recent Activity</a></li>
|
||||
<li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:profile' %}">Profile</a>
|
||||
</li>
|
||||
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:change_password' %}">Change Password</a>
|
||||
</li>
|
||||
<li{% ifequal active_tab "api_tokens" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:token_list' %}">API Tokens</a>
|
||||
</li>
|
||||
<li{% ifequal active_tab "userkey" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:userkey' %}">User Key</a>
|
||||
</li>
|
||||
<li{% ifequal active_tab "recent_activity" %} class="active"{% endifequal %}>
|
||||
<a href="{% url 'user:recent_activity' %}">Recent Activity</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-sm-9 col-md-6">
|
||||
|
||||
58
netbox/templates/users/api_tokens.html
Normal file
58
netbox/templates/users/api_tokens.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends 'users/_user.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block title %}API Tokens{% endblock %}
|
||||
|
||||
{% block usercontent %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% for token in tokens %}
|
||||
<div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
|
||||
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
|
||||
</div>
|
||||
<i class="fa fa-key"></i> {{ token.key }}
|
||||
{% if token.is_expired %}
|
||||
<span class="label label-danger">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<span title="{{ token.created }}">{{ token.created|date }}</span><br />
|
||||
<small class="text-muted">Created</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% if token.expires %}
|
||||
<span title="{{ token.expires }}">{{ token.expires|date }}</span><br />
|
||||
{% else %}
|
||||
<span>Never</span><br />
|
||||
{% endif %}
|
||||
<small class="text-muted">Expires</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% if token.write_enabled %}
|
||||
<span class="label label-success">Enabled</span>
|
||||
{% else %}
|
||||
<span class="label label-danger">Disabled</span>
|
||||
{% endif %}<br />
|
||||
<small class="text-muted">Create/edit/delete operations</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if token.description %}
|
||||
<br /><span>{{ token.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>You do not have any API tokens.</p>
|
||||
{% endfor %}
|
||||
<a href="{% url 'user:token_add' %}" class="btn btn-primary">
|
||||
<span class="fa fa-plus" aria-hidden="true"></span>
|
||||
Add a token
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
5
netbox/templates/users/sessionkey_delete.html
Normal file
5
netbox/templates/users/sessionkey_delete.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% extends 'utilities/obj_delete.html' %}
|
||||
|
||||
{% block message %}
|
||||
<p>Are you sure you want to delete your session key?</p>
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
{% block usercontent %}
|
||||
{% if userkey %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'user:userkey_edit' %}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span>
|
||||
Edit user key
|
||||
</a>
|
||||
</div>
|
||||
<h4>
|
||||
Your user key is:
|
||||
{% if userkey.is_active %}
|
||||
@@ -12,15 +18,21 @@
|
||||
<span class="label label-danger">Inactive</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<p>Your public key is below.</p>
|
||||
<pre>{{ userkey.public_key }}</pre>
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'user:userkey_edit' %}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span>
|
||||
Edit user key
|
||||
</a>
|
||||
</div>
|
||||
{% include 'inc/created_updated.html' with obj=userkey %}
|
||||
<pre>{{ userkey.public_key }}</pre>
|
||||
<hr />
|
||||
{% if userkey.session_key %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'user:sessionkey_delete' %}" class="btn btn-danger">
|
||||
<span class="fa fa-trash" aria-hidden="true"></span>
|
||||
Delete session key
|
||||
</a>
|
||||
</div>
|
||||
<h4>Session key: <span class="label label-success">Active</span></h4>
|
||||
<small class="text-muted">Created {{ userkey.session_key.created }}</small>
|
||||
{% else %}
|
||||
<h4>No active session key</h4>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>You don't have a user key on file.</p>
|
||||
<p>
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<form action="." method="post" class="form">
|
||||
{% csrf_token %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
<div class="panel panel-{{ panel_class|default:"danger" }}">
|
||||
<div class="panel-heading">{% block title %}{% endblock %}</div>
|
||||
<div class="panel-body">
|
||||
{% block message %}<p>Are you sure?</p>{% endblock %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
<div class="form-group">
|
||||
<div class="checkbox{% if form.confirm.errors %} has-error{% endif %}">
|
||||
<label for="{{ form.confirm.id_for_label }}">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user