Compare commits

...

128 Commits

Author SHA1 Message Date
Jeremy Stretch
f5f9491811 v2.0 Beta 1 release 2017-03-22 10:07:37 -04:00
Jeremy Stretch
04e09c0078 Merge branch 'develop' into api2
Conflicts:
	netbox/circuits/filters.py
2017-03-22 09:48:41 -04:00
Jeremy Stretch
05b71564d8 Closes #981: Allow filtering primary objects by a given set of IDs 2017-03-22 09:39:30 -04:00
Jeremy Stretch
1791a5bb11 Added has_primary_ip filter for Devices 2017-03-21 21:29:03 -04:00
Jeremy Stretch
3e6a99fc22 Allow editing of platform RPC client 2017-03-21 17:33:40 -04:00
Jeremy Stretch
a5419ecc5c RPC API fixes 2017-03-21 17:24:16 -04:00
Jeremy Stretch
a36b138efe Added API doc for working with secrets 2017-03-21 16:00:02 -04:00
Jeremy Stretch
6d30fdb83d Finished work on secrets views; removed path from cookie assignment 2017-03-21 15:30:36 -04:00
Jeremy Stretch
5c4741c5d4 Added section on pagination 2017-03-21 14:34:52 -04:00
Jeremy Stretch
93c748bd3c Merge branch 'develop' into api2 2017-03-21 14:10:53 -04:00
Jeremy Stretch
7ba6e320e7 Fixes #843: Implemented CORS headers for API 2017-03-21 13:53:07 -04:00
Jeremy Stretch
54468ab1a8 Include the API version in responses 2017-03-21 13:23:56 -04:00
Jeremy Stretch
01f5435f63 Tweak how we set the API version 2017-03-21 13:17:50 -04:00
Jeremy Stretch
22768ff6c6 Renamed Module to InventoryItem (prep for #824) 2017-03-21 12:54:08 -04:00
Jeremy Stretch
122526a9d0 Custom name for ConnectedDeviceViewSet 2017-03-20 21:54:01 -04:00
Jeremy Stretch
6cb36a6cee Fixed browsable API breadcrumbs 2017-03-20 21:50:10 -04:00
Jeremy Stretch
925afe0999 Added test case for ConnectedDeviceViewSet 2017-03-20 21:39:40 -04:00
Jeremy Stretch
f743410b4e Renamed rack-units API and added a test 2017-03-20 21:18:37 -04:00
Jeremy Stretch
4a2206ecb1 Removed custom renderers 2017-03-20 17:47:18 -04:00
Jeremy Stretch
ffde2c96c7 Fixed custom renderers to work with paginated data 2017-03-20 17:15:42 -04:00
Jeremy Stretch
2bd46230be Converted ChoiceFieldSerializer to display an object 2017-03-20 16:32:59 -04:00
Jeremy Stretch
b04fe21d65 Wrote API endpoints, tests for ExportTemplates 2017-03-20 16:21:10 -04:00
Jeremy Stretch
266f9cc370 Added API endpoint, tests for Graphs 2017-03-20 15:14:33 -04:00
Jeremy Stretch
1682de59df Added a footer link to the GitHub wiki 2017-03-20 14:05:26 -04:00
Jeremy Stretch
42fd14f5c0 Introduced HttpStatusMixin to provide more detail on HTTP response status test failures 2017-03-20 13:46:47 -04:00
Jeremy Stretch
1988c02b7f Enforce API versioning 2017-03-20 12:33:42 -04:00
Jeremy Stretch
517eaa8b80 Expanded API documentation 2017-03-20 12:18:18 -04:00
Jeremy Stretch
1f78462f58 Updated RackViewSet() to be compatible with paginated API 2017-03-20 10:38:09 -04:00
Jeremy Stretch
36bbcc8559 Fix API JS to read response.results for new API 2017-03-20 10:06:25 -04:00
Jeremy Stretch
1c1fd8f210 Limit tests to one per major Python version 2017-03-17 21:43:46 -04:00
Jeremy Stretch
671d53877a Python3 fixes 2017-03-17 21:39:29 -04:00
Jeremy Stretch
97710a4576 Make CI happy 2017-03-17 17:39:56 -04:00
Jeremy Stretch
c08fae8bce Restore not-so-extraneous 'id' field to all WritableSerializers 2017-03-17 17:32:43 -04:00
Jeremy Stretch
f02dd2f439 Merge branch 'develop' into api2 2017-03-17 17:06:01 -04:00
Jeremy Stretch
e544f1fa1e Removed extraneous 'id' field from all WritableSerializers 2017-03-17 16:20:34 -04:00
Jeremy Stretch
130ff27f26 Wrote tests for secrets API 2017-03-17 16:01:57 -04:00
Jeremy Stretch
79a9ac3bc8 Assign RackReservation user from request context 2017-03-17 14:45:14 -04:00
Jeremy Stretch
c5308d51f4 Make RackReservation.rack editble for API compatability 2017-03-17 14:40:11 -04:00
Jeremy Stretch
a6f4de5817 Wrote tests for IPAM API 2017-03-17 14:36:59 -04:00
Jeremy Stretch
8825a03033 Removed unneeded services endpoint from DCIM API 2017-03-17 12:23:23 -04:00
Jeremy Stretch
abdfc5c597 Finished DCIM API model tests 2017-03-17 12:16:24 -04:00
Jeremy Stretch
3ce2f0d100 Fix error when assigning a new interface to a LAG 2017-03-16 22:27:01 -04:00
Jeremy Stretch
be2faaa110 Fixed bug interpreting facility_id as a required field 2017-03-16 17:25:34 -04:00
Jeremy Stretch
f33269e50b First batch of DCIM API tests 2017-03-16 16:50:18 -04:00
Jeremy Stretch
bbc355df07 Improved create/update validation 2017-03-16 14:17:14 -04:00
Jeremy Stretch
d58f9031d1 Wrote tests for tenancy API 2017-03-16 13:29:55 -04:00
Jeremy Stretch
0312016f89 Wrote tests for circuits API 2017-03-16 13:23:01 -04:00
Jeremy Stretch
e3ae013e42 Implemented full read/write support for secrets 2017-03-15 14:47:18 -04:00
Jeremy Stretch
07a2b136b8 Refactored SecretViewSet 2017-03-15 13:48:09 -04:00
Jeremy Stretch
3d76a982aa Removed old API doc 2017-03-15 13:15:09 -04:00
Jeremy Stretch
92d726bbd4 Added examples to the graphs documentation 2017-03-15 12:16:46 -04:00
Jeremy Stretch
e2ef0bc3a6 Added survey announcement to README 2017-03-15 12:00:53 -04:00
Jeremy Stretch
13c29cb7a9 Post-release version bump 2017-03-14 17:18:05 -04:00
Jeremy Stretch
3dc15068b9 Allow user to delete session key 2017-03-14 14:01:06 -04:00
Jeremy Stretch
4cb30f1ce4 Relate SessionKey to UserKey rather than User 2017-03-14 13:32:07 -04:00
Jeremy Stretch
b868de8d67 Updated user URLs 2017-03-14 12:59:10 -04:00
Jeremy Stretch
04aedcc056 Merge branch 'develop' into api2
Conflicts:
	netbox/templates/users/_user.html
	netbox/users/urls.py
2017-03-14 12:40:28 -04:00
Jeremy Stretch
105d17748e Secrets UI work 2017-03-14 12:32:08 -04:00
Jeremy Stretch
dd27950fae Simplify SessionKey usage 2017-03-14 10:58:57 -04:00
Jeremy Stretch
9e4e3a8dfa Updated API docs 2017-03-13 10:00:13 -04:00
Jeremy Stretch
4d4441217f APIRootView tweaks 2017-03-09 15:18:50 -05:00
Jeremy Stretch
7e51ca9912 Provided a root API view 2017-03-09 15:05:01 -05:00
Jeremy Stretch
94a29be415 Removed deprecated GraphListView 2017-03-09 14:28:52 -05:00
Jeremy Stretch
9dfda83946 Closes #855: Added an API endpoint for recent activity 2017-03-09 14:26:39 -05:00
Jeremy Stretch
41826fc3cb Fixed serialization of CustomFieldChoices 2017-03-09 13:50:30 -05:00
Jeremy Stretch
0ed13f6943 Removed browsable API login/logout 2017-03-09 13:38:15 -05:00
Jeremy Stretch
6c2ed1be22 Standardized API URL definitions 2017-03-09 13:24:02 -05:00
Jeremy Stretch
ddec424429 Replaced RelatedConnectionsView with views.ConnectedDeviceViewSet 2017-03-09 12:18:53 -05:00
Jeremy Stretch
7e6d061646 Converted GetSessionKey and RSAKeyGeneratorView to ViewSets 2017-03-08 17:57:51 -05:00
Jeremy Stretch
c19725506d Cleanup 2017-03-08 16:30:32 -05:00
Jeremy Stretch
a6ceaf8d96 Moved custom field serializers to their own module to avoid circular dependency 2017-03-08 16:18:41 -05:00
Jeremy Stretch
f43fbffdf7 Moved TopologyMaps from DCIM to extras 2017-03-08 16:12:14 -05:00
Jeremy Stretch
68c099a2af Merge branch 'develop' into api2
Conflicts:
	netbox/netbox/settings.py
2017-03-08 15:18:32 -05:00
Jeremy Stretch
4f6d2a8b71 Finished user control panel for tokens 2017-03-08 11:34:47 -05:00
Jeremy Stretch
d58a8ebba0 Initial work on user control panel for tokens 2017-03-07 23:30:53 -05:00
Jeremy Stretch
6be465fe9b Addded is_expired property to Token 2017-03-07 23:30:31 -05:00
Jeremy Stretch
26225aff57 Shorten key length to 20 bytes 2017-03-07 22:56:29 -05:00
Jeremy Stretch
fd55360672 Suppress default permissions for Token model 2017-03-07 22:40:05 -05:00
Jeremy Stretch
0b10d98e0b Initial work on token authentication 2017-03-07 17:17:39 -05:00
Jeremy Stretch
be0a3fb1f2 Corrected merge conflict 2017-03-07 16:55:49 -05:00
Jeremy Stretch
02e89d77bb Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/views.py
2017-03-07 14:08:06 -05:00
Jeremy Stretch
a7a7b956b1 Enable API versioning 2017-03-02 16:20:16 -05:00
Jeremy Stretch
9b39ba169c Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/urls.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
	netbox/dcim/tables.py
	requirements.txt
2017-03-02 16:01:25 -05:00
Jeremy Stretch
90fe556e5f Corrected region serializers 2017-02-28 16:23:39 -05:00
Jeremy Stretch
c0152940f9 Merged develop 2017-02-28 16:10:53 -05:00
Jeremy Stretch
8f42f59a80 Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
2017-02-27 17:04:08 -05:00
Jeremy Stretch
f1518226bd Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/serializers.py
2017-02-17 15:12:53 -05:00
Jeremy Stretch
21281789e0 Tweaked ChoiceFieldSerializer to display a field as (value, label) 2017-02-16 14:37:21 -05:00
Jeremy Stretch
b71566f206 Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/urls.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
2017-02-16 14:28:06 -05:00
Jeremy Stretch
0e04d20762 Re-implemented CustomFieldSerializer (read-only for now) 2017-02-09 16:55:54 -05:00
Jeremy Stretch
7040086201 Introduced ChoiceFieldSerializer for choice fields 2017-02-09 15:50:25 -05:00
Jeremy Stretch
6f3c3b6d61 Added API endpoints for device type components 2017-02-03 17:18:47 -05:00
Jeremy Stretch
37f250ddc1 Corrected API URL names 2017-02-03 16:54:13 -05:00
Jeremy Stretch
35f310885e Standardize API URL inclusions 2017-02-03 16:20:14 -05:00
Jeremy Stretch
616ca4fe1f Adapted the web UI to work with the new secrets API 2017-02-03 16:14:42 -05:00
Jeremy Stretch
a9fe39459a Merge branch 'develop' into api2 2017-02-03 14:45:37 -05:00
Jeremy Stretch
a42eeb12d2 Implemented SessionKeys for secrets 2017-02-03 12:49:32 -05:00
Jeremy Stretch
cf66f67fb6 Initial work on using session-based master key ciphers 2017-02-02 21:26:51 -05:00
Jeremy Stretch
2408d78f47 Introduced ability to decrypt secrets by sending the user's private key in an HTTP header 2017-02-01 17:40:50 -05:00
Jeremy Stretch
4f8a5eb1a0 Moved secret views into a ViewSet (no write ability yet) 2017-02-01 16:21:33 -05:00
Jeremy Stretch
06e5966cb4 Include API routers directly where possible 2017-02-01 15:09:23 -05:00
Jeremy Stretch
ea51f1c896 Removed circuit-specific endpoint for CircuitTerminations 2017-02-01 15:01:56 -05:00
Jeremy Stretch
77e5450746 Removed all device-specific API endpoints 2017-02-01 14:34:19 -05:00
Jeremy Stretch
6e10fea119 Started API documentation 2017-02-01 14:04:45 -05:00
Jeremy Stretch
f52c247bd5 Re-implemented Swagger now that URL resolution has been fixed 2017-02-01 12:37:19 -05:00
Jeremy Stretch
0dd857f7a2 Merge branch 'develop' into api2 2017-02-01 12:33:37 -05:00
Jeremy Stretch
bb1f97abc2 Implemented static writable ModelSerializers for all models 2017-01-31 15:35:09 -05:00
Jeremy Stretch
e1cd846c9a Enabled creation of device components 2017-01-31 12:19:41 -05:00
Jeremy Stretch
1fcc2b0029 Namespaced all API URLs 2017-01-31 10:40:53 -05:00
Jeremy Stretch
173a6eee03 Moved rack units and device LLDP neighbors views into model viewsets 2017-01-30 17:24:04 -05:00
Jeremy Stretch
d9e4017677 Moved graph views into model viewsets 2017-01-30 17:00:58 -05:00
Jeremy Stretch
7beac0b105 Converted device component views to a router 2017-01-30 16:15:12 -05:00
Jeremy Stretch
f0fef94a4f Re-implemented interface/connection serializers 2017-01-30 15:35:01 -05:00
Jeremy Stretch
78cd4481e4 Merge branch 'develop' into api2 2017-01-30 13:38:49 -05:00
Jeremy Stretch
0cf029edd4 Added Service serializers 2017-01-27 16:19:38 -05:00
Jeremy Stretch
c0dac1383d Fix retrieval of model under viewsets without a statically defined queryset 2017-01-27 15:12:46 -05:00
Jeremy Stretch
a3d0d4a5bf Enabled pagination 2017-01-27 14:54:12 -05:00
Jeremy Stretch
12d263999b Introduced WritableSerializerMixin 2017-01-27 14:36:13 -05:00
Jeremy Stretch
fa900d5dbb Converted nested serializers to HyperlinkedModelSerializer 2017-01-27 12:22:29 -05:00
Jeremy Stretch
ddc2c8d110 Cleaned up device component nested serializers 2017-01-26 22:37:17 -05:00
Jeremy Stretch
acfba410dd Standardized implementation of nested ViewSets 2017-01-26 17:58:36 -05:00
Jeremy Stretch
b8ca530c55 Added an endpoint for CircuitTerminations 2017-01-26 17:18:41 -05:00
Jeremy Stretch
b31c097531 Removed Swagger 2017-01-26 15:36:19 -05:00
Jeremy Stretch
0f9fe8648e Converted static URL definitions to routers 2017-01-26 15:34:07 -05:00
Jeremy Stretch
791a641eef Created CircuitDetailSerializer 2017-01-26 15:33:41 -05:00
Jeremy Stretch
c5fba24cc5 Merge branch 'develop' into api2 2017-01-26 14:07:23 -05:00
Jeremy Stretch
0b228ed6d3 Merge branch 'develop' into api2 2017-01-25 16:26:45 -05:00
Jeremy Stretch
062a5bfe8d Initial work on API v2.0 2017-01-24 17:12:16 -05:00
97 changed files with 6793 additions and 2452 deletions

View File

@@ -9,9 +9,7 @@ env:
language: python language: python
python: python:
- "2.7" - "2.7"
- "3.4"
- "3.5" - "3.5"
- "3.6"
install: install:
- pip install -r requirements.txt - pip install -r requirements.txt
- pip install pep8 - pip install pep8

View File

@@ -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](docs/netbox_logo.png "NetBox logo") ![NetBox](docs/netbox_logo.png "NetBox logo")
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. 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.

View File

@@ -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.

View 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
View 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
View 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": [...]
}
```

View 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.

View File

@@ -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 ## DEBUG
Default: False Default: False

View File

@@ -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. 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 # 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. 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 ### 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. 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. 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.

View File

@@ -90,6 +90,22 @@ NetBox does not have the ability to generate graphs natively, but this feature a
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. * **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.
* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. * **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`.
## Examples
You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this:
```
https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m
```
You can define several graphs to provide multiple contexts when viewing an object. For example:
```
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h
https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
```
# Topology Maps # Topology Maps
NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps. NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps.

View File

@@ -19,7 +19,11 @@ pages:
- 'Secrets': 'data-model/secrets.md' - 'Secrets': 'data-model/secrets.md'
- 'Tenancy': 'data-model/tenancy.md' - 'Tenancy': 'data-model/tenancy.md'
- 'Extras': 'data-model/extras.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: markdown_extensions:
- admonition: - admonition:

View File

@@ -1,27 +1,38 @@
from rest_framework import serializers from rest_framework import serializers
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
from extras.api.serializers import CustomFieldSerializer from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import TenantNestedSerializer from tenancy.api.serializers import NestedTenantSerializer
# #
# Providers # Providers
# #
class ProviderSerializer(CustomFieldSerializer, serializers.ModelSerializer): class ProviderSerializer(CustomFieldModelSerializer):
class Meta: class Meta:
model = Provider model = Provider
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', fields = [
'custom_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): class Meta:
fields = ['id', 'name', 'slug'] 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'] fields = ['id', 'name', 'slug']
class CircuitTypeNestedSerializer(CircuitTypeSerializer): class NestedCircuitTypeSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
class Meta(CircuitTypeSerializer.Meta): class Meta:
pass model = CircuitType
fields = ['id', 'url', 'name', 'slug']
# #
# Circuits # Circuits
# #
class CircuitTerminationSerializer(serializers.ModelSerializer): class CircuitSerializer(CustomFieldModelSerializer):
site = SiteNestedSerializer() provider = NestedProviderSerializer()
interface = InterfaceNestedSerializer() type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer()
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 Meta: class Meta:
model = Circuit model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', fields = [
'terminations', 'custom_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): class Meta:
fields = ['id', 'cid'] 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',
]

View File

@@ -1,25 +1,25 @@
from django.conf.urls import url from rest_framework import routers
from extras.models import GRAPH_TYPE_PROVIDER from . import views
from extras.api.views import GraphListView
from .views import *
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 router = routers.DefaultRouter()
url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'), router.APIRootView = CircuitsRootView
url(r'^circuit-types/(?P<pk>\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'),
# Circuits # Providers
url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'), router.register(r'providers', views.ProviderViewSet)
url(r'^circuits/(?P<pk>\d+)/$', CircuitDetailView.as_view(), name='circuit_detail'),
] # Circuits
router.register(r'circuit-types', views.CircuitTypeViewSet)
router.register(r'circuits', views.CircuitViewSet)
router.register(r'circuit-terminations', views.CircuitTerminationViewSet)
urlpatterns = router.urls

View File

@@ -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 rest_framework.decorators import detail_route
from circuits.filters import CircuitFilter 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 from . import serializers
class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView): #
""" # Providers
List all providers #
"""
queryset = Provider.objects.prefetch_related('custom_field_values__field') class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = Provider.objects.all()
serializer_class = serializers.ProviderSerializer 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): #
""" # Circuit Types
Retrieve a single provider #
"""
queryset = Provider.objects.prefetch_related('custom_field_values__field')
serializer_class = serializers.ProviderSerializer
class CircuitTypeViewSet(ModelViewSet):
class CircuitTypeListView(generics.ListAPIView):
"""
List all circuit types
"""
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
serializer_class = serializers.CircuitTypeSerializer serializer_class = serializers.CircuitTypeSerializer
class CircuitTypeDetailView(generics.RetrieveAPIView): #
""" # Circuits
Retrieve a single circuit type #
"""
queryset = CircuitType.objects.all()
serializer_class = serializers.CircuitTypeSerializer
class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView): queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
"""
List circuits (filterable)
"""
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitSerializer
filter_class = CircuitFilter write_serializer_class = serializers.WritableCircuitSerializer
filter_class = filters.CircuitFilter
class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): #
""" # Circuit Terminations
Retrieve a single circuit #
"""
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\ class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
.prefetch_related('custom_field_values__field') queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitTerminationSerializer
write_serializer_class = serializers.WritableCircuitTerminationSerializer
filter_class = filters.CircuitTerminationFilter

View File

@@ -5,12 +5,12 @@ from django.db.models import Q
from dcim.models import Site from dcim.models import Site
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Provider, Circuit, CircuitTermination, CircuitType
from .models import Provider, Circuit, CircuitType
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -42,6 +42,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -105,3 +106,15 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
Q(description__icontains=value) | Q(description__icontains=value) |
Q(comments__icontains=value) Q(comments__icontains=value)
).distinct() ).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']

View File

View 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)

View File

@@ -5,7 +5,7 @@ from mptt.admin import MPTTModelAdmin
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, 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, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region,
Site, Site,
) )
@@ -183,8 +183,8 @@ class DeviceBayAdmin(admin.TabularInline):
readonly_fields = ['installed_device'] readonly_fields = ['installed_device']
class ModuleAdmin(admin.TabularInline): class InventoryItemAdmin(admin.TabularInline):
model = Module model = InventoryItem
readonly_fields = ['parent', 'discovered'] readonly_fields = ['parent', 'discovered']
@@ -197,7 +197,7 @@ class DeviceAdmin(admin.ModelAdmin):
PowerOutletAdmin, PowerOutletAdmin,
InterfaceAdmin, InterfaceAdmin,
DeviceBayAdmin, DeviceBayAdmin,
ModuleAdmin, InventoryItemAdmin,
] ]
list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag', list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
'serial'] 'serial']

View File

@@ -1,28 +1,40 @@
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from ipam.models import IPAddress from ipam.models import IPAddress
from dcim.models import ( from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType, CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_FRONT, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
RACK_FACE_REAR, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, 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 extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import TenantNestedSerializer from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer
# #
# Regions # Regions
# #
class RegionNestedSerializer(serializers.ModelSerializer): class NestedRegionSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
class Meta: class Meta:
model = Region model = Region
fields = ['id', 'name', 'slug'] fields = ['id', 'url', 'name', 'slug']
class RegionSerializer(serializers.ModelSerializer): class RegionSerializer(serializers.ModelSerializer):
parent = NestedRegionSerializer()
class Meta:
model = Region
fields = ['id', 'name', 'slug', 'parent']
class WritableRegionSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Region model = Region
@@ -33,21 +45,35 @@ class RegionSerializer(serializers.ModelSerializer):
# Sites # Sites
# #
class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): class SiteSerializer(CustomFieldModelSerializer):
region = RegionNestedSerializer() region = NestedRegionSerializer()
tenant = TenantNestedSerializer() tenant = NestedTenantSerializer()
class Meta: class Meta:
model = Site model = Site
fields = ['id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', fields = [
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] '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): class Meta:
fields = ['id', 'name', 'slug'] 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): class RackGroupSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer() site = NestedSiteSerializer()
class Meta: class Meta:
model = RackGroup model = RackGroup
fields = ['id', 'name', 'slug', 'site'] 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): class Meta:
fields = ['id', 'name', 'slug'] 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'] 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): class Meta:
fields = ['id', 'name', 'slug'] model = RackRole
fields = ['id', 'url', 'name', 'slug']
# #
# Racks # Racks
# #
class RackReservationNestedSerializer(serializers.ModelSerializer): class RackSerializer(CustomFieldModelSerializer):
site = NestedSiteSerializer()
class Meta: group = NestedRackGroupSerializer()
model = RackReservation tenant = NestedTenantSerializer()
fields = ['id', 'units', 'created', 'user', 'description'] role = NestedRackRoleSerializer()
type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES)
width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES)
class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
site = SiteNestedSerializer()
group = RackGroupNestedSerializer()
tenant = TenantNestedSerializer()
role = RackRoleNestedSerializer()
class Meta: class Meta:
model = Rack model = Rack
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', fields = [
'u_height', 'desc_units', 'comments', 'custom_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): class Meta:
fields = ['id', 'name', 'facility_id', 'display_name'] model = Rack
fields = ['id', 'url', 'name', 'display_name']
class RackDetailSerializer(RackSerializer): class WritableRackSerializer(serializers.ModelSerializer):
front_units = serializers.SerializerMethodField()
rear_units = serializers.SerializerMethodField()
reservations = RackReservationNestedSerializer(many=True)
class Meta(RackSerializer.Meta): class Meta:
fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', model = Rack
'u_height', 'desc_units', 'reservations', 'comments', 'custom_fields', 'front_units', 'rear_units'] 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): def validate(self, data):
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 get_rear_units(self, obj): # Validate uniqueness of (site, facility_id) since we omitted the automatically-created validator from Meta.
units = obj.get_rack_units(face=RACK_FACE_REAR) if data.get('facility_id', None):
for u in units: validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'facility_id'))
u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None validator.set_context(self)
return units 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): class RackReservationSerializer(serializers.ModelSerializer):
rack = RackNestedSerializer() rack = NestedRackSerializer()
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = ['id', 'rack', 'units', 'created', 'user', 'description'] fields = ['id', 'rack', 'units', 'created', 'user', 'description']
class WritableRackReservationSerializer(serializers.ModelSerializer):
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'description']
# #
# Manufacturers # Manufacturers
# #
@@ -159,88 +227,165 @@ class ManufacturerSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class ManufacturerNestedSerializer(ManufacturerSerializer): class NestedManufacturerSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
class Meta(ManufacturerSerializer.Meta): class Meta:
pass model = Manufacturer
fields = ['id', 'url', 'name', 'slug']
# #
# Device types # Device types
# #
class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer): class DeviceTypeSerializer(CustomFieldModelSerializer):
manufacturer = ManufacturerNestedSerializer() manufacturer = NestedManufacturerSerializer()
subdevice_role = serializers.SerializerMethodField() interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES)
subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES)
instance_count = serializers.IntegerField(source='instances.count', read_only=True) instance_count = serializers.IntegerField(source='instances.count', read_only=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', fields = [
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
'comments', 'custom_fields', 'instance_count'] '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]
class DeviceTypeNestedSerializer(DeviceTypeSerializer): class NestedDeviceTypeSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
class Meta(DeviceTypeSerializer.Meta): class Meta:
fields = ['id', 'manufacturer', 'model', 'slug'] 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: class Meta:
model = ConsolePortTemplate 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: class Meta:
model = ConsoleServerPortTemplate 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: class Meta:
model = PowerPortTemplate 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: class Meta:
model = PowerOutletTemplate 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: class Meta:
model = InterfaceTemplate model = InterfaceTemplate
fields = ['id', 'name', 'form_factor', 'mgmt_only'] fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
class DeviceTypeDetailSerializer(DeviceTypeSerializer): class WritableInterfaceTemplateSerializer(serializers.ModelSerializer):
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 Meta(DeviceTypeSerializer.Meta): class Meta:
fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', model = InterfaceTemplate
'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates',
'power_outlet_templates', 'interface_templates']
#
# 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'] 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): class Meta:
fields = ['id', 'name', 'slug'] model = DeviceRole
fields = ['id', 'url', 'name', 'slug']
# #
@@ -271,34 +418,39 @@ class PlatformSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'rpc_client'] 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): class Meta:
fields = ['id', 'name', 'slug'] model = Platform
fields = ['id', 'url', 'name', 'slug']
# #
# Devices # Devices
# #
# Cannot import ipam.api.IPAddressNestedSerializer due to circular dependency # Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
class DeviceIPAddressNestedSerializer(serializers.ModelSerializer): class DeviceIPAddressSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['id', 'family', 'address'] fields = ['id', 'url', 'family', 'address']
class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): class DeviceSerializer(CustomFieldModelSerializer):
device_type = DeviceTypeNestedSerializer() device_type = NestedDeviceTypeSerializer()
device_role = DeviceRoleNestedSerializer() device_role = NestedDeviceRoleSerializer()
tenant = TenantNestedSerializer() tenant = NestedTenantSerializer()
platform = PlatformNestedSerializer() platform = NestedPlatformSerializer()
site = SiteNestedSerializer() site = NestedSiteSerializer()
rack = RackNestedSerializer() rack = NestedRackSerializer()
primary_ip = DeviceIPAddressNestedSerializer() face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
primary_ip4 = DeviceIPAddressNestedSerializer() status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
primary_ip6 = DeviceIPAddressNestedSerializer() primary_ip = DeviceIPAddressSerializer()
primary_ip4 = DeviceIPAddressSerializer()
primary_ip6 = DeviceIPAddressSerializer()
parent_device = serializers.SerializerMethodField() parent_device = serializers.SerializerMethodField()
class Meta: class Meta:
@@ -324,11 +476,25 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
} }
class DeviceNestedSerializer(serializers.ModelSerializer): class WritableDeviceSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Device 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): class ConsoleServerPortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer() device = NestedDeviceSerializer()
class Meta: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = ['id', 'device', 'name', 'connected_console'] 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'] fields = ['id', 'device', 'name']
@@ -354,18 +522,19 @@ class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer):
# #
class ConsolePortSerializer(serializers.ModelSerializer): class ConsolePortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer() device = NestedDeviceSerializer()
cs_port = ConsoleServerPortNestedSerializer() cs_port = ConsoleServerPortSerializer()
class Meta: class Meta:
model = ConsolePort model = ConsolePort
fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
class ConsolePortNestedSerializer(ConsolePortSerializer): class WritableConsolePortSerializer(serializers.ModelSerializer):
class Meta(ConsolePortSerializer.Meta): class Meta:
fields = ['id', 'device', 'name'] model = ConsolePort
fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
# #
@@ -373,16 +542,18 @@ class ConsolePortNestedSerializer(ConsolePortSerializer):
# #
class PowerOutletSerializer(serializers.ModelSerializer): class PowerOutletSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer() device = NestedDeviceSerializer()
class Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = ['id', 'device', 'name', 'connected_port'] 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'] fields = ['id', 'device', 'name']
@@ -391,104 +562,103 @@ class PowerOutletNestedSerializer(PowerOutletSerializer):
# #
class PowerPortSerializer(serializers.ModelSerializer): class PowerPortSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer() device = NestedDeviceSerializer()
power_outlet = PowerOutletNestedSerializer() power_outlet = PowerOutletSerializer()
class Meta: class Meta:
model = PowerPort model = PowerPort
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
class PowerPortNestedSerializer(PowerPortSerializer): class WritablePowerPortSerializer(serializers.ModelSerializer):
class Meta(PowerPortSerializer.Meta): class Meta:
fields = ['id', 'device', 'name'] model = PowerPort
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
# #
# Interfaces # 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): class InterfaceSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer() device = NestedDeviceSerializer()
form_factor = serializers.ReadOnlyField(source='get_form_factor_display') form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
lag = LAGInterfaceNestedSerializer() connection = serializers.SerializerMethodField(read_only=True)
connected_interface = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = Interface model = Interface
fields = [ 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',
]
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',
'connected_interface', '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 # Device bays
# #
class DeviceBaySerializer(serializers.ModelSerializer): class DeviceBaySerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer() device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer()
class Meta: class Meta:
model = DeviceBay model = DeviceBay
fields = ['id', 'device', 'name'] fields = ['id', 'device', 'name', 'installed_device']
class DeviceBayNestedSerializer(DeviceBaySerializer): class WritableDeviceBaySerializer(serializers.ModelSerializer):
installed_device = DeviceNestedSerializer()
class Meta(DeviceBaySerializer.Meta): class Meta:
fields = ['id', 'name', 'installed_device'] model = DeviceBay
class DeviceBayDetailSerializer(DeviceBaySerializer):
installed_device = DeviceNestedSerializer()
class Meta(DeviceBaySerializer.Meta):
fields = ['id', 'device', 'name', 'installed_device'] fields = ['id', 'device', 'name', 'installed_device']
# #
# Modules # Inventory items
# #
class ModuleSerializer(serializers.ModelSerializer): class InventoryItemSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer() device = NestedDeviceSerializer()
manufacturer = ManufacturerNestedSerializer() manufacturer = NestedManufacturerSerializer()
class Meta: class Meta:
model = Module model = InventoryItem
fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
class ModuleNestedSerializer(ModuleSerializer): class WritableInventoryItemSerializer(serializers.ModelSerializer):
class Meta(ModuleSerializer.Meta): class Meta:
fields = ['id', 'device', 'parent', 'name'] model = InventoryItem
fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
# #
@@ -496,6 +666,24 @@ class ModuleNestedSerializer(ModuleSerializer):
# #
class InterfaceConnectionSerializer(serializers.ModelSerializer): 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: class Meta:
model = InterfaceConnection model = InterfaceConnection

View File

@@ -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 . import views
from extras.api.views import GraphListView, TopologyMapView
from .views import *
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 router = routers.DefaultRouter()
url(r'^sites/$', SiteListView.as_view(), name='site_list'), router.APIRootView = DCIMRootView
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'),
# Rack groups # Sites
url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'), router.register(r'regions', views.RegionViewSet)
url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'), router.register(r'sites', views.SiteViewSet)
# Rack roles # Racks
url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'), router.register(r'rack-groups', views.RackGroupViewSet)
url(r'^rack-roles/(?P<pk>\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'), router.register(r'rack-roles', views.RackRoleViewSet)
router.register(r'racks', views.RackViewSet)
router.register(r'rack-reservations', views.RackReservationViewSet)
# Racks # Device types
url(r'^racks/$', RackListView.as_view(), name='rack_list'), router.register(r'manufacturers', views.ManufacturerViewSet)
url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'), router.register(r'device-types', views.DeviceTypeViewSet)
url(r'^racks/(?P<pk>\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'),
# Rack reservations # Device type components
url(r'^rack-reservations/$', RackReservationListView.as_view(), name='rackreservation_list'), router.register(r'console-port-templates', views.ConsolePortTemplateViewSet)
url(r'^rack-reservations/(?P<pk>\d+)/$', RackReservationDetailView.as_view(), name='rackreservation_detail'), 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 # Devices
url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'), router.register(r'device-roles', views.DeviceRoleViewSet)
url(r'^manufacturers/(?P<pk>\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'), router.register(r'platforms', views.PlatformViewSet)
router.register(r'devices', views.DeviceViewSet)
# Device types # Device components
url(r'^device-types/$', DeviceTypeListView.as_view(), name='devicetype_list'), router.register(r'console-ports', views.ConsolePortViewSet)
url(r'^device-types/(?P<pk>\d+)/$', DeviceTypeDetailView.as_view(), name='devicetype_detail'), 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 # Interface connections
url(r'^device-roles/$', DeviceRoleListView.as_view(), name='devicerole_list'), router.register(r'interface-connections', views.InterfaceConnectionViewSet)
url(r'^device-roles/(?P<pk>\d+)/$', DeviceRoleDetailView.as_view(), name='devicerole_detail'),
# Platforms # Miscellaneous
url(r'^platforms/$', PlatformListView.as_view(), name='platform_list'), router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
url(r'^platforms/(?P<pk>\d+)/$', PlatformDetailView.as_view(), name='platform_detail'),
# Devices urlpatterns = router.urls
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'),
]

View File

@@ -1,23 +1,22 @@
from rest_framework import generics from rest_framework.decorators import detail_route, list_route
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.settings import api_settings from rest_framework.viewsets import ModelViewSet, ViewSet
from rest_framework.views import APIView
from django.conf import settings 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 django.shortcuts import get_object_or_404
from dcim.models import ( from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
VIRTUAL_IFACE_TYPES, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
RackRole, Region, Site,
) )
from dcim import filters from dcim import filters
from extras.api.views import CustomFieldModelAPIView from extras.api.serializers import RenderedGraphSerializer
from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer from extras.api.views import CustomFieldModelViewSet
from utilities.api import ServiceUnavailable from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from utilities.api import ServiceUnavailable, WritableSerializerMixin
from .exceptions import MissingFilterException from .exceptions import MissingFilterException
from . import serializers from . import serializers
@@ -26,79 +25,49 @@ from . import serializers
# Regions # Regions
# #
class RegionListView(generics.ListAPIView): class RegionViewSet(WritableSerializerMixin, ModelViewSet):
"""
List all regions
"""
queryset = Region.objects.all()
serializer_class = serializers.RegionSerializer
class RegionDetailView(generics.RetrieveAPIView):
"""
Retrieve a single region
"""
queryset = Region.objects.all() queryset = Region.objects.all()
serializer_class = serializers.RegionSerializer serializer_class = serializers.RegionSerializer
write_serializer_class = serializers.WritableRegionSerializer
# #
# Sites # Sites
# #
class SiteListView(CustomFieldModelAPIView, generics.ListAPIView): class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
""" queryset = Site.objects.select_related('region', 'tenant')
List all sites
"""
queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
filter_class = filters.SiteFilter
write_serializer_class = serializers.WritableSiteSerializer
@detail_route()
class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): def graphs(self, request, pk=None):
""" """
Retrieve a single site A convenience method for rendering graphs for a particular site.
""" """
queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field') site = get_object_or_404(Site, pk=pk)
serializer_class = serializers.SiteSerializer queryset = Graph.objects.filter(type=GRAPH_TYPE_SITE)
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
return Response(serializer.data)
# #
# Rack groups # Rack groups
# #
class RackGroupListView(generics.ListAPIView): class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
"""
List all rack groups
"""
queryset = RackGroup.objects.select_related('site') queryset = RackGroup.objects.select_related('site')
serializer_class = serializers.RackGroupSerializer serializer_class = serializers.RackGroupSerializer
filter_class = filters.RackGroupFilter filter_class = filters.RackGroupFilter
write_serializer_class = serializers.WritableRackGroupSerializer
class RackGroupDetailView(generics.RetrieveAPIView):
"""
Retrieve a single rack group
"""
queryset = RackGroup.objects.select_related('site')
serializer_class = serializers.RackGroupSerializer
# #
# Rack roles # Rack roles
# #
class RackRoleListView(generics.ListAPIView): class RackRoleViewSet(ModelViewSet):
"""
List all rack roles
"""
queryset = RackRole.objects.all()
serializer_class = serializers.RackRoleSerializer
class RackRoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single rack role
"""
queryset = RackRole.objects.all() queryset = RackRole.objects.all()
serializer_class = serializers.RackRoleSerializer serializer_class = serializers.RackRoleSerializer
@@ -107,36 +76,17 @@ class RackRoleDetailView(generics.RetrieveAPIView):
# Racks # Racks
# #
class RackListView(CustomFieldModelAPIView, generics.ListAPIView): class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
""" queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
List racks (filterable)
"""
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.RackSerializer serializer_class = serializers.RackSerializer
write_serializer_class = serializers.WritableRackSerializer
filter_class = filters.RackFilter filter_class = filters.RackFilter
@detail_route()
class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): def units(self, request, pk=None):
""" """
Retrieve a single rack List rack units (by 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):
rack = get_object_or_404(Rack, pk=pk) rack = get_object_or_404(Rack, pk=pk)
face = request.GET.get('face', 0) face = request.GET.get('face', 0)
exclude_pk = request.GET.get('exclude', None) exclude_pk = request.GET.get('exclude', None)
@@ -147,92 +97,97 @@ class RackUnitListView(APIView):
exclude_pk = None exclude_pk = None
elevation = rack.get_rack_units(face, exclude_pk) elevation = rack.get_rack_units(face, exclude_pk)
# Serialize Devices within the rack elevation page = self.paginate_queryset(elevation)
for u in elevation: if page is not None:
if u['device']: rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
u['device'] = serializers.DeviceNestedSerializer(instance=u['device']).data return self.get_paginated_response(rack_units.data)
return Response(elevation)
# #
# Rack reservations # Rack reservations
# #
class RackReservationListView(generics.ListAPIView): class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
""" queryset = RackReservation.objects.select_related('rack')
List all rack reservation
"""
queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer serializer_class = serializers.RackReservationSerializer
write_serializer_class = serializers.WritableRackReservationSerializer
filter_class = filters.RackReservationFilter filter_class = filters.RackReservationFilter
# Assign user from request
class RackReservationDetailView(generics.RetrieveAPIView): def perform_create(self, serializer):
""" serializer.save(user=self.request.user)
Retrieve a single rack reservation
"""
queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer
# #
# Manufacturers # Manufacturers
# #
class ManufacturerListView(generics.ListAPIView): class ManufacturerViewSet(ModelViewSet):
"""
List all hardware manufacturers
"""
queryset = Manufacturer.objects.all()
serializer_class = serializers.ManufacturerSerializer
class ManufacturerDetailView(generics.RetrieveAPIView):
"""
Retrieve a single hardware manufacturers
"""
queryset = Manufacturer.objects.all() queryset = Manufacturer.objects.all()
serializer_class = serializers.ManufacturerSerializer serializer_class = serializers.ManufacturerSerializer
# #
# Device Types # Device types
# #
class DeviceTypeListView(CustomFieldModelAPIView, generics.ListAPIView): class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
""" queryset = DeviceType.objects.select_related('manufacturer')
List device types (filterable)
"""
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
serializer_class = serializers.DeviceTypeSerializer serializer_class = serializers.DeviceTypeSerializer
filter_class = filters.DeviceTypeFilter write_serializer_class = serializers.WritableDeviceTypeSerializer
class DeviceTypeDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): #
""" # Device type components
Retrieve a single device type #
"""
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field') class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
serializer_class = serializers.DeviceTypeDetailSerializer 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 # Device roles
# #
class DeviceRoleListView(generics.ListAPIView): class DeviceRoleViewSet(ModelViewSet):
"""
List all device roles
"""
queryset = DeviceRole.objects.all()
serializer_class = serializers.DeviceRoleSerializer
class DeviceRoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single device role
"""
queryset = DeviceRole.objects.all() queryset = DeviceRole.objects.all()
serializer_class = serializers.DeviceRoleSerializer serializer_class = serializers.DeviceRoleSerializer
@@ -241,18 +196,7 @@ class DeviceRoleDetailView(generics.RetrieveAPIView):
# Platforms # Platforms
# #
class PlatformListView(generics.ListAPIView): class PlatformViewSet(ModelViewSet):
"""
List all platforms
"""
queryset = Platform.objects.all()
serializer_class = serializers.PlatformSerializer
class PlatformDetailView(generics.RetrieveAPIView):
"""
Retrieve a single platform
"""
queryset = Platform.objects.all() queryset = Platform.objects.all()
serializer_class = serializers.PlatformSerializer serializer_class = serializers.PlatformSerializer
@@ -261,284 +205,142 @@ class PlatformDetailView(generics.RetrieveAPIView):
# Devices # Devices
# #
class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView): class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
"""
List devices (filterable)
"""
queryset = Device.objects.select_related( 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( ).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 serializer_class = serializers.DeviceSerializer
write_serializer_class = serializers.WritableDeviceSerializer
filter_class = filters.DeviceFilter 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) device = get_object_or_404(Device, pk=pk)
if not device.primary_ip: 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() RPC = device.get_rpc_client()
if not RPC: 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 # Connect to device and retrieve inventory info
try: try:
with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client: with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client:
lldp_neighbors = rpc_client.get_lldp_neighbors() lldp_neighbors = rpc_client.get_lldp_neighbors()
except: except:
raise ServiceUnavailable(detail="Error connecting to the remote device.") raise ServiceUnavailable("Error connecting to the remote device.")
return Response(lldp_neighbors) 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 # 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): def get_view_name(self):
super(RelatedConnectionsView, self).__init__() return "Connected Device Locator"
# Custom fields def list(self, request):
self.content_type = ContentType.objects.get_for_model(Device)
self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
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') # Determine local interface from peer interface's connection
peer_interface = request.GET.get('peer-interface') 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 local_interface is None:
if peer_device and peer_interface: return Response()
# Determine local interface from peer interface's connection return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data)
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)

View File

@@ -5,15 +5,17 @@ from django.db.models import Q
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import ( from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site, DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate,
VIRTUAL_IFACE_TYPES, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
) )
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -81,6 +83,7 @@ class RackGroupFilter(django_filters.FilterSet):
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -157,6 +160,7 @@ class RackReservationFilter(django_filters.FilterSet):
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -190,7 +194,64 @@ 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): class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -291,6 +352,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
name='device_type__is_network_device', name='device_type__is_network_device',
label='Is a network device', label='Is a network device',
) )
has_primary_ip = django_filters.BooleanFilter(
method='_has_primary_ip',
label='Has a primary IP',
)
class Meta: class Meta:
model = Device model = Device
@@ -302,7 +367,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(name__icontains=value) |
Q(serial__icontains=value.strip()) | Q(serial__icontains=value.strip()) |
Q(modules__serial__icontains=value.strip()) | Q(inventory_items__serial__icontains=value.strip()) |
Q(asset_tag=value.strip()) | Q(asset_tag=value.strip()) |
Q(comments__icontains=value) Q(comments__icontains=value)
).distinct() ).distinct()
@@ -316,8 +381,20 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError: except AddrFormatError:
return queryset.none() 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( device_id = django_filters.ModelMultipleChoiceFilter(
name='device', name='device',
queryset=Device.objects.all(), queryset=Device.objects.all(),
@@ -330,77 +407,36 @@ class ConsolePortFilter(django_filters.FilterSet):
label='Device (name)', label='Device (name)',
) )
class ConsolePortFilter(DeviceComponentFilterSet):
class Meta: class Meta:
model = ConsolePort model = ConsolePort
fields = ['name'] fields = ['name']
class ConsoleServerPortFilter(django_filters.FilterSet): class ConsoleServerPortFilter(DeviceComponentFilterSet):
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 Meta: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = ['name'] fields = ['name']
class PowerPortFilter(django_filters.FilterSet): class PowerPortFilter(DeviceComponentFilterSet):
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 Meta: class Meta:
model = PowerPort model = PowerPort
fields = ['name'] fields = ['name']
class PowerOutletFilter(django_filters.FilterSet): class PowerOutletFilter(DeviceComponentFilterSet):
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 Meta: class Meta:
model = PowerOutlet model = PowerOutlet
fields = ['name'] fields = ['name']
class InterfaceFilter(django_filters.FilterSet): class InterfaceFilter(DeviceComponentFilterSet):
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)',
)
type = django_filters.CharFilter( type = django_filters.CharFilter(
method='filter_type', method='filter_type',
label='Interface type', label='Interface type',
@@ -421,6 +457,20 @@ class InterfaceFilter(django_filters.FilterSet):
return queryset return queryset
class DeviceBayFilter(DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ['name']
class InventoryItemFilter(DeviceComponentFilterSet):
class Meta:
model = InventoryItem
fields = ['name']
class ConsoleConnectionFilter(django_filters.FilterSet): class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',

View File

@@ -21,7 +21,7 @@ from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, 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, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
VIRTUAL_IFACE_TYPES VIRTUAL_IFACE_TYPES
) )
@@ -496,7 +496,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ['name', 'slug'] fields = ['name', 'slug', 'rpc_client']
# #
@@ -512,7 +512,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
)) ))
position = forms.TypedChoiceField(required=False, empty_value=None, position = forms.TypedChoiceField(required=False, empty_value=None,
help_text="The lowest-numbered unit occupied by the device", 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')) disabled_indicator='device'))
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
widget=forms.Select(attrs={'filter-for': 'device_type'})) widget=forms.Select(attrs={'filter-for': 'device_type'}))
@@ -1684,11 +1684,11 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
# #
# Modules # Inventory items
# #
class ModuleForm(BootstrapMixin, forms.ModelForm): class InventoryItemForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = Module model = InventoryItem
fields = ['name', 'manufacturer', 'part_id', 'serial'] fields = ['name', 'manufacturer', 'part_id', 'serial']

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

View File

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

View File

@@ -533,7 +533,7 @@ class RackReservation(models.Model):
""" """
One or more reserved units within a Rack. 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()) units = ArrayField(models.PositiveSmallIntegerField())
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT) user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT)
@@ -1397,19 +1397,19 @@ class DeviceBay(models.Model):
# #
# Modules # Inventory items
# #
@python_2_unicode_compatible @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 An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
for inventory purposes. InventoryItems are used only for inventory purposes.
""" """
device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE) device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE)
parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, 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') 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) on_delete=models.PROTECT)
part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) 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) serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)

View File

@@ -347,12 +347,13 @@ class PlatformTable(BaseTable):
name = tables.LinkColumn(verbose_name='Name') name = tables.LinkColumn(verbose_name='Name')
device_count = tables.Column(verbose_name='Devices') device_count = tables.Column(verbose_name='Devices')
slug = tables.Column(verbose_name='Slug') 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'}}, actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
verbose_name='') verbose_name='')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Platform model = Platform
fields = ('pk', 'name', 'device_count', 'slug', 'actions') fields = ('pk', 'name', 'device_count', 'slug', 'rpc_client', 'actions')
# #

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -173,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+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), 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 # Console/power/interface connections
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), 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'), url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
@@ -181,9 +186,4 @@ urlpatterns = [
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), 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'), 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'),
] ]

View File

@@ -25,7 +25,7 @@ from . import filters, forms, tables
from .models import ( from .models import (
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, 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, RackReservation, RackRole, Region, Site,
) )
@@ -90,7 +90,12 @@ class ComponentCreateView(View):
self.parent_field: parent.pk, self.parent_field: parent.pk,
'name': name, 'name': name,
} }
component_data.update(data) # Replace objects with their primary key to keep component_form.clean() happy
for k, v in data.items():
if hasattr(v, 'pk'):
component_data[k] = v.pk
else:
component_data[k] = v
component_form = self.model_form(component_data) component_form = self.model_form(component_data)
if component_form.is_valid(): if component_form.is_valid():
new_components.append(component_form.save(commit=False)) new_components.append(component_form.save(commit=False))
@@ -794,12 +799,12 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
def device_inventory(request, pk): def device_inventory(request, pk):
device = get_object_or_404(Device, pk=pk) device = get_object_or_404(Device, pk=pk)
modules = Module.objects.filter(device=device, parent=None).select_related('manufacturer')\ inventory_items = InventoryItem.objects.filter(device=device, parent=None).select_related('manufacturer')\
.prefetch_related('submodules') .prefetch_related('child_items')
return render(request, 'dcim/device_inventory.html', { return render(request, 'dcim/device_inventory.html', {
'device': device, 'device': device,
'modules': modules, 'inventory_items': inventory_items,
}) })
@@ -1589,13 +1594,13 @@ def ipaddress_assign(request, pk):
# #
# Modules # Inventory items
# #
class ModuleEditView(PermissionRequiredMixin, ComponentEditView): class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_module' permission_required = 'dcim.change_inventoryitem'
model = Module model = InventoryItem
form_class = forms.ModuleForm form_class = forms.InventoryItemForm
def alter_obj(self, obj, request, url_args, url_kwargs): def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs: if 'device' in url_kwargs:
@@ -1603,6 +1608,6 @@ class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
return obj return obj
class ModuleDeleteView(PermissionRequiredMixin, ComponentDeleteView): class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_module' permission_required = 'dcim.delete_inventoryitem'
model = Module model = InventoryItem

View 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']

View File

@@ -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)

View File

@@ -1,56 +1,84 @@
from rest_framework import serializers 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): #
""" # Graphs
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']
class GraphSerializer(serializers.ModelSerializer): class GraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField() type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
embed_link = serializers.SerializerMethodField()
class Meta: class Meta:
model = Graph 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): def get_embed_url(self, obj):
return obj.embed_url(self.context['graphed_object']) return obj.embed_url(self.context['graphed_object'])
def get_embed_link(self, obj): def get_embed_link(self, obj):
return obj.embed_link(self.context['graphed_object']) 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
View 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

View File

@@ -1,115 +1,90 @@
import graphviz from rest_framework.decorators import detail_route
from rest_framework import generics from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rest_framework.views import APIView
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.http import HttpResponse
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from circuits.models import Provider from extras import filters
from dcim.models import Site, Device, Interface, InterfaceConnection from extras.models import ExportTemplate, Graph, TopologyMap, UserAction
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE from utilities.api import WritableSerializerMixin
from . import serializers
from .serializers import GraphSerializer
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): def get_serializer_context(self):
super(CustomFieldModelAPIView, self).__init__()
self.content_type = ContentType.objects.get_for_model(self.queryset.model) # Gather all custom fields for the model
self.custom_fields = self.content_type.custom_fields.prefetch_related('choices') 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. # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object.
custom_field_choices = {} custom_field_choices = {}
for field in self.custom_fields: for field in custom_fields:
for cfc in field.choices.all(): for cfc in field.choices.all():
custom_field_choices[cfc.id] = cfc.value custom_field_choices[cfc.id] = cfc.value
self.custom_field_choices = custom_field_choices custom_field_choices = custom_field_choices
context = super(CustomFieldModelViewSet, self).get_serializer_context()
class GraphListView(generics.ListAPIView): context.update({
""" 'custom_fields': custom_fields,
Returns a list of relevant graphs 'custom_field_choices': custom_field_choices,
""" })
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'])})
return context return context
def get_queryset(self): def get_queryset(self):
graph_type = self.kwargs.get('type', None) # Prefetch custom field values
if not graph_type: return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field')
raise Http404()
queryset = Graph.objects.filter(type=graph_type)
return queryset
class TopologyMapView(APIView): class GraphViewSet(WritableSerializerMixin, ModelViewSet):
""" queryset = Graph.objects.all()
Generate a topology diagram 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)) class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
subgraph.graph_attr['rank'] = 'same' 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 @detail_route()
subgraph.node('set{}'.format(i), label='', shape='none', width='0') def render(self, request, pk):
if i:
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
# Add each device to the graph tmap = get_object_or_404(TopologyMap, pk=pk)
devices = [] img_format = 'png'
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 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: try:
topo_data = graph.pipe(format='png') data = tmap.render(img_format=img_format)
except: except:
return HttpResponse("There was an error generating the requested graph. Ensure that the GraphViz " return HttpResponse(
"executables have been installed correctly.") "There was an error generating the requested graph. Ensure that the GraphViz executables have been "
response = HttpResponse(topo_data, content_type='image/png') "installed correctly."
)
response = HttpResponse(data, content_type='image/{}'.format(img_format))
response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, img_format)
return response 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

View File

@@ -1,8 +1,10 @@
import django_filters import django_filters
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType 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): 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) custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
for cf in custom_fields: for cf in custom_fields:
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type) 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']

View File

@@ -6,7 +6,7 @@ from django.conf import settings
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.db import transaction from django.db import transaction
from dcim.models import Device, Module, Site from dcim.models import Device, InventoryItem, Site
class Command(BaseCommand): class Command(BaseCommand):
@@ -25,12 +25,12 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
def create_modules(modules, parent=None): def create_inventory_items(inventory_items, parent=None):
for module in modules: for item in inventory_items:
m = Module(device=device, parent=parent, name=module['name'], part_id=module['part_id'], i = InventoryItem(device=device, parent=parent, name=item['name'], part_id=item['part_id'],
serial=module['serial'], discovered=True) serial=item['serial'], discovered=True)
m.save() i.save()
create_modules(module.get('modules', []), parent=m) create_inventory_items(item.get('items', []), parent=i)
# Credentials # Credentials
if options['username']: if options['username']:
@@ -107,9 +107,9 @@ class Command(BaseCommand):
self.stdout.write("") self.stdout.write("")
self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial'])) self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial']))
self.stdout.write("\tDescription: {}".format(inventory['chassis']['description'])) self.stdout.write("\tDescription: {}".format(inventory['chassis']['description']))
for module in inventory['modules']: for item in inventory['items']:
self.stdout.write("\tModule: {} / {} ({})".format(module['name'], module['part_id'], self.stdout.write("\tItem: {} / {} ({})".format(item['name'], item['part_id'],
module['serial'])) item['serial']))
else: else:
self.stdout.write("{} ({})".format(inventory['chassis']['description'], inventory['chassis']['serial'])) self.stdout.write("{} ({})".format(inventory['chassis']['description'], inventory['chassis']['serial']))
@@ -119,7 +119,7 @@ class Command(BaseCommand):
if device.serial != inventory['chassis']['serial']: if device.serial != inventory['chassis']['serial']:
device.serial = inventory['chassis']['serial'] device.serial = inventory['chassis']['serial']
device.save() device.save()
Module.objects.filter(device=device, discovered=True).delete() InventoryItem.objects.filter(device=device, discovered=True).delete()
create_modules(inventory.get('modules', [])) create_inventory_items(inventory.get('items', []))
self.stdout.write("Finished!") self.stdout.write("Finished!")

View File

@@ -1,11 +1,13 @@
from collections import OrderedDict from collections import OrderedDict
from datetime import date from datetime import date
import graphviz
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.db.models import Q
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Template, Context from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
@@ -66,6 +68,10 @@ ACTION_CHOICES = (
) )
#
# Custom fields
#
class CustomFieldModel(object): class CustomFieldModel(object):
def cf(self): def cf(self):
@@ -211,6 +217,10 @@ class CustomFieldChoice(models.Model):
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete() CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
#
# Graphs
#
@python_2_unicode_compatible @python_2_unicode_compatible
class Graph(models.Model): class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
@@ -236,6 +246,10 @@ class Graph(models.Model):
return template.render(Context({'obj': obj})) return template.render(Context({'obj': obj}))
#
# Export templates
#
@python_2_unicode_compatible @python_2_unicode_compatible
class ExportTemplate(models.Model): class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}) content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
@@ -270,6 +284,10 @@ class ExportTemplate(models.Model):
return response return response
#
# Topology maps
#
@python_2_unicode_compatible @python_2_unicode_compatible
class TopologyMap(models.Model): class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
@@ -294,6 +312,56 @@ class TopologyMap(models.Model):
return None return None
return [line.strip() for line in self.device_patterns.split('\n')] 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): class UserActionManager(models.Manager):

View File

@@ -33,14 +33,14 @@ class RPCClient(object):
def get_inventory(self): 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': { 'chassis': {
'serial': <str>, 'serial': <str>,
'description': <str>, 'description': <str>,
} }
'modules': [ 'items': [
{ {
'name': <str>, 'name': <str>,
'part_id': <str>, 'part_id': <str>,
@@ -130,8 +130,11 @@ class JunosNC(RPCClient):
for neighbor_raw in lldp_neighbors_raw: for neighbor_raw in lldp_neighbors_raw:
neighbor = dict() neighbor = dict()
neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id') neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id')
neighbor['name'] = neighbor_raw.get('lldp-remote-system-name') name = neighbor_raw.get('lldp-remote-system-name')
neighbor['name'] = neighbor['name'].split('.')[0] # Split hostname from domain if one is present if name:
neighbor['name'] = name.split('.')[0] # Split hostname from domain if one is present
else:
neighbor['name'] = ''
try: try:
neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description'] neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description']
except KeyError: except KeyError:
@@ -144,23 +147,23 @@ class JunosNC(RPCClient):
def get_inventory(self): def get_inventory(self):
def glean_modules(node, depth=0): def glean_items(node, depth=0):
modules = [] items = []
modules_list = node.get('chassis{}-module'.format('-sub' * depth), []) items_list = node.get('chassis{}-module'.format('-sub' * depth), [])
# Junos like to return single children directly instead of as a single-item list # Junos like to return single children directly instead of as a single-item list
if hasattr(modules_list, 'items'): if hasattr(items_list, 'items'):
modules_list = [modules_list] items_list = [items_list]
for module in modules_list: for item in items_list:
m = { m = {
'name': module['name'], 'name': item['name'],
'part_id': module.get('model-number') or module.get('part-number', ''), 'part_id': item.get('model-number') or item.get('part-number', ''),
'serial': module.get('serial-number', ''), 'serial': item.get('serial-number', ''),
} }
submodules = glean_modules(module, depth + 1) child_items = glean_items(item, depth + 1)
if submodules: if child_items:
m['modules'] = submodules m['items'] = child_items
modules.append(m) items.append(m)
return modules return items
rpc_reply = self.manager.dispatch('get-chassis-inventory') rpc_reply = self.manager.dispatch('get-chassis-inventory')
inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis'] inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis']
@@ -173,8 +176,8 @@ class JunosNC(RPCClient):
'description': inventory_raw['description'], 'description': inventory_raw['description'],
} }
# Gather modules # Gather inventory items
result['modules'] = glean_modules(inventory_raw) result['items'] = glean_items(inventory_raw)
return result return result
@@ -199,7 +202,7 @@ class IOSSSH(SSHClient):
'description': parse(sh_ver, 'cisco ([^\s]+)') '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') cmd = self._send('show inventory').split('\r\n\r\n')
for i in cmd: for i in cmd:
i_fmt = i.replace('\r\n', ' ') i_fmt = i.replace('\r\n', ' ')
@@ -207,7 +210,7 @@ class IOSSSH(SSHClient):
m_name = re.search('NAME: "([^"]+)"', i_fmt).group(1) m_name = re.search('NAME: "([^"]+)"', i_fmt).group(1)
m_pid = re.search('PID: ([^\s]+)', i_fmt).group(1) m_pid = re.search('PID: ([^\s]+)', i_fmt).group(1)
m_serial = re.search('SN: ([^\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': if m_serial != chassis_serial and m_pid.lower() != 'unspecified':
yield { yield {
'name': m_name, 'name': m_name,
@@ -222,7 +225,7 @@ class IOSSSH(SSHClient):
return { return {
'chassis': sh_version, '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, 'serial': serial,
'description': description, 'description': description,
}, },
'modules': [], 'items': [],
} }

View 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)

View File

@@ -1,36 +1,41 @@
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
from extras.api.serializers import CustomFieldSerializer from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import (
from tenancy.api.serializers import TenantNestedSerializer 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 # VRFs
# #
class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer): class VRFSerializer(CustomFieldModelSerializer):
tenant = TenantNestedSerializer() tenant = NestedTenantSerializer()
class Meta: class Meta:
model = VRF model = VRF
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] 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): class Meta:
fields = ['id', 'name', 'rd'] model = VRF
fields = ['id', 'url', 'name', 'rd']
class VRFTenantSerializer(VRFSerializer): class WritableVRFSerializer(serializers.ModelSerializer):
"""
Include tenant serializer. Useful for determining tenant inheritance for Prefixes and IPAddresses.
"""
class Meta(VRFSerializer.Meta): class Meta:
fields = ['id', 'name', 'rd', 'tenant'] model = VRF
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
# #
@@ -44,10 +49,12 @@ class RoleSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'weight'] 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): class Meta:
fields = ['id', 'name', 'slug'] model = Role
fields = ['id', 'url', 'name', 'slug']
# #
@@ -61,28 +68,39 @@ class RIRSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug', 'is_private'] 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): class Meta:
fields = ['id', 'name', 'slug'] model = RIR
fields = ['id', 'url', 'name', 'slug']
# #
# Aggregates # Aggregates
# #
class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer): class AggregateSerializer(CustomFieldModelSerializer):
rir = RIRNestedSerializer() rir = NestedRIRSerializer()
class Meta: class Meta:
model = Aggregate model = Aggregate
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields'] 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): 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): class VLANGroupSerializer(serializers.ModelSerializer):
site = SiteNestedSerializer() site = NestedSiteSerializer()
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = ['id', 'name', 'slug', 'site'] 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): class Meta:
fields = ['id', 'name', 'slug'] 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 # VLANs
# #
class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer): class VLANSerializer(CustomFieldModelSerializer):
site = SiteNestedSerializer() site = NestedSiteSerializer()
group = VLANGroupNestedSerializer() group = NestedVLANGroupSerializer()
tenant = TenantNestedSerializer() tenant = NestedTenantSerializer()
role = RoleNestedSerializer() status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES)
role = NestedRoleSerializer()
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name', fields = [
'custom_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): class Meta:
fields = ['id', 'vid', 'name', 'display_name'] 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 # Prefixes
# #
class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer): class PrefixSerializer(CustomFieldModelSerializer):
site = SiteNestedSerializer() site = NestedSiteSerializer()
vrf = VRFTenantSerializer() vrf = NestedVRFSerializer()
tenant = TenantNestedSerializer() tenant = NestedTenantSerializer()
vlan = VLANNestedSerializer() vlan = NestedVLANSerializer()
role = RoleNestedSerializer() status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES)
role = NestedRoleSerializer()
class Meta: class Meta:
model = Prefix model = Prefix
fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', fields = [
'custom_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): class Meta:
fields = ['id', 'family', 'prefix'] 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 # IP addresses
# #
class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer): class IPAddressSerializer(CustomFieldModelSerializer):
vrf = VRFTenantSerializer() vrf = NestedVRFSerializer()
tenant = TenantNestedSerializer() tenant = NestedTenantSerializer()
interface = InterfaceNestedSerializer() status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
interface = InterfaceSerializer()
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', fields = [
'nat_outside', 'custom_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): class Meta:
fields = ['id', 'family', 'address'] model = IPAddress
fields = ['id', 'url', 'family', 'address']
IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer() IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer() 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): class ServiceSerializer(serializers.ModelSerializer):
device = DeviceNestedSerializer() device = NestedDeviceSerializer()
ipaddresses = IPAddressNestedSerializer(many=True) protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
ipaddresses = NestedIPAddressSerializer(many=True)
class Meta: class Meta:
model = Service model = Service
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
class ServiceNestedSerializer(ServiceSerializer): class WritableServiceSerializer(serializers.ModelSerializer):
class Meta(ServiceSerializer.Meta): class Meta:
fields = ['id', 'name', 'port', 'protocol'] model = Service
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']

View File

@@ -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 router = routers.DefaultRouter()
url(r'^roles/$', RoleListView.as_view(), name='role_list'), router.APIRootView = IPAMRootView
url(r'^roles/(?P<pk>\d+)/$', RoleDetailView.as_view(), name='role_detail'),
# RIRs # VRFs
url(r'^rirs/$', RIRListView.as_view(), name='rir_list'), router.register(r'vrfs', views.VRFViewSet)
url(r'^rirs/(?P<pk>\d+)/$', RIRDetailView.as_view(), name='rir_detail'),
# Aggregates # RIRs
url(r'^aggregates/$', AggregateListView.as_view(), name='aggregate_list'), router.register(r'rirs', views.RIRViewSet)
url(r'^aggregates/(?P<pk>\d+)/$', AggregateDetailView.as_view(), name='aggregate_detail'),
# Prefixes # Aggregates
url(r'^prefixes/$', PrefixListView.as_view(), name='prefix_list'), router.register(r'aggregates', views.AggregateViewSet)
url(r'^prefixes/(?P<pk>\d+)/$', PrefixDetailView.as_view(), name='prefix_detail'),
# IP addresses # Prefixes
url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'), router.register(r'roles', views.RoleViewSet)
url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'), router.register(r'prefixes', views.PrefixViewSet)
# VLAN groups # IP addresses
url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'), router.register(r'ip-addresses', views.IPAddressViewSet)
url(r'^vlan-groups/(?P<pk>\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'),
# VLANs # VLANs
url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'), router.register(r'vlan-groups', views.VLANGroupViewSet)
url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'), router.register(r'vlans', views.VLANViewSet)
# Services # Services
url(r'^services/$', ServiceListView.as_view(), name='service_list'), router.register(r'services', views.ServiceViewSet)
url(r'^services/(?P<pk>\d+)/$', ServiceDetailView.as_view(), name='service_detail'),
] urlpatterns = router.urls

View File

@@ -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.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from ipam import filters from ipam import filters
from extras.api.views import CustomFieldModelViewSet
from extras.api.views import CustomFieldModelAPIView from utilities.api import WritableSerializerMixin
from . import serializers from . import serializers
@@ -11,39 +11,18 @@ from . import serializers
# VRFs # VRFs
# #
class VRFListView(CustomFieldModelAPIView, generics.ListAPIView): class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
""" queryset = VRF.objects.select_related('tenant')
List all VRFs
"""
queryset = VRF.objects.select_related('tenant').prefetch_related('custom_field_values__field')
serializer_class = serializers.VRFSerializer serializer_class = serializers.VRFSerializer
write_serializer_class = serializers.WritableVRFSerializer
filter_class = filters.VRFFilter 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 # Roles
# #
class RoleListView(generics.ListAPIView): class RoleViewSet(ModelViewSet):
"""
List all roles
"""
queryset = Role.objects.all()
serializer_class = serializers.RoleSerializer
class RoleDetailView(generics.RetrieveAPIView):
"""
Retrieve a single role
"""
queryset = Role.objects.all() queryset = Role.objects.all()
serializer_class = serializers.RoleSerializer serializer_class = serializers.RoleSerializer
@@ -52,18 +31,7 @@ class RoleDetailView(generics.RetrieveAPIView):
# RIRs # RIRs
# #
class RIRListView(generics.ListAPIView): class RIRViewSet(ModelViewSet):
"""
List all RIRs
"""
queryset = RIR.objects.all()
serializer_class = serializers.RIRSerializer
class RIRDetailView(generics.RetrieveAPIView):
"""
Retrieve a single RIR
"""
queryset = RIR.objects.all() queryset = RIR.objects.all()
serializer_class = serializers.RIRSerializer serializer_class = serializers.RIRSerializer
@@ -72,129 +40,62 @@ class RIRDetailView(generics.RetrieveAPIView):
# Aggregates # Aggregates
# #
class AggregateListView(CustomFieldModelAPIView, generics.ListAPIView): class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
""" queryset = Aggregate.objects.select_related('rir')
List aggregates (filterable)
"""
queryset = Aggregate.objects.select_related('rir').prefetch_related('custom_field_values__field')
serializer_class = serializers.AggregateSerializer serializer_class = serializers.AggregateSerializer
write_serializer_class = serializers.WritableAggregateSerializer
filter_class = filters.AggregateFilter 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 # Prefixes
# #
class PrefixListView(CustomFieldModelAPIView, generics.ListAPIView): class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
""" queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
List prefixes (filterable)
"""
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.PrefixSerializer serializer_class = serializers.PrefixSerializer
write_serializer_class = serializers.WritablePrefixSerializer
filter_class = filters.PrefixFilter 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 # IP addresses
# #
class IPAddressListView(CustomFieldModelAPIView, generics.ListAPIView): class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
""" queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
List IP addresses (filterable)
"""
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')\
.prefetch_related('nat_outside', 'custom_field_values__field')
serializer_class = serializers.IPAddressSerializer serializer_class = serializers.IPAddressSerializer
write_serializer_class = serializers.WritableIPAddressSerializer
filter_class = filters.IPAddressFilter 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 # VLAN groups
# #
class VLANGroupListView(generics.ListAPIView): class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
"""
List all VLAN groups
"""
queryset = VLANGroup.objects.select_related('site') queryset = VLANGroup.objects.select_related('site')
serializer_class = serializers.VLANGroupSerializer serializer_class = serializers.VLANGroupSerializer
write_serializer_class = serializers.WritableVLANGroupSerializer
filter_class = filters.VLANGroupFilter filter_class = filters.VLANGroupFilter
class VLANGroupDetailView(generics.RetrieveAPIView):
"""
Retrieve a single VLAN group
"""
queryset = VLANGroup.objects.select_related('site')
serializer_class = serializers.VLANGroupSerializer
# #
# VLANs # VLANs
# #
class VLANListView(CustomFieldModelAPIView, generics.ListAPIView): class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
""" queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
List VLANs (filterable)
"""
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\
.prefetch_related('custom_field_values__field')
serializer_class = serializers.VLANSerializer serializer_class = serializers.VLANSerializer
write_serializer_class = serializers.WritableVLANSerializer
filter_class = filters.VLANFilter 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 # Services
# #
class ServiceListView(generics.ListAPIView): class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
""" queryset = Service.objects.select_related('device')
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')
serializer_class = serializers.ServiceSerializer serializer_class = serializers.ServiceSerializer
write_serializer_class = serializers.WritableServiceSerializer

View File

@@ -7,12 +7,13 @@ from django.db.models import Q
from dcim.models import Site, Device, Interface from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -44,6 +45,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RIRFilter(django_filters.FilterSet): class RIRFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
class Meta: class Meta:
model = RIR model = RIR
@@ -51,6 +53,7 @@ class RIRFilter(django_filters.FilterSet):
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -84,6 +87,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -182,6 +186,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -283,6 +288,7 @@ class VLANGroupFilter(django_filters.FilterSet):
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View 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)

View File

@@ -38,6 +38,26 @@ ADMINS = [
# ['John Doe', 'jdoe@example.com'], # ['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 settings
EMAIL = { EMAIL = {
'SERVER': 'localhost', 'SERVER': 'localhost',
@@ -48,24 +68,28 @@ EMAIL = {
'FROM_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 # 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. # are permitted to access most data in NetBox (excluding secrets) but not make any changes.
LOGIN_REQUIRED = False 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. # Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = False 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_USERNAME = ''
NETBOX_PASSWORD = '' NETBOX_PASSWORD = ''
# Determine how many objects to display per page within a list. (Default: 50) # Determine how many objects to display per page within a list. (Default: 50)
PAGINATE_COUNT = 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 (default: UTC)
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
@@ -77,16 +101,3 @@ TIME_FORMAT = 'g:i a'
SHORT_TIME_FORMAT = 'H:i:s' SHORT_TIME_FORMAT = 'H:i:s'
DATETIME_FORMAT = 'N j, Y g:i a' DATETIME_FORMAT = 'N j, Y g:i a'
SHORT_DATETIME_FORMAT = 'Y-m-d H:i' 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

View File

@@ -8,19 +8,22 @@ from django.core.exceptions import ImproperlyConfigured
try: try:
from netbox import configuration from netbox import configuration
except ImportError: except ImportError:
raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per " raise ImproperlyConfigured(
"the documentation.") "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
)
VERSION = '1.9.2' VERSION = '2.0-beta1'
# Import local configuration # Import local configuration
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
try: try:
globals()[setting] = getattr(configuration, setting) globals()[setting] = getattr(configuration, setting)
except AttributeError: except AttributeError:
raise ImproperlyConfigured("Mandatory setting {} is missing from configuration.py. Please define it per the " raise ImproperlyConfigured(
"documentation.".format(setting)) "Mandatory setting {} is missing from configuration.py.".format(setting)
)
# Default configurations # Default configurations
ADMINS = getattr(configuration, 'ADMINS', []) ADMINS = getattr(configuration, 'ADMINS', [])
@@ -45,6 +48,9 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False) BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', 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 CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# Attempt to import LDAP configuration if it has been defined # Attempt to import LDAP configuration if it has been defined
@@ -73,8 +79,10 @@ if LDAP_CONFIGURED:
logger.addHandler(logging.StreamHandler()) logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
except ImportError: except ImportError:
raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. " raise ImproperlyConfigured(
"You can remove netbox/ldap_config.py to disable LDAP.") "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__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -102,6 +110,7 @@ INSTALLED_APPS = (
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.humanize', 'django.contrib.humanize',
'corsheaders',
'debug_toolbar', 'debug_toolbar',
'django_tables2', 'django_tables2',
'mptt', 'mptt',
@@ -120,6 +129,7 @@ INSTALLED_APPS = (
# Middleware # Middleware
MIDDLEWARE = ( MIDDLEWARE = (
'debug_toolbar.middleware.DebugToolbarMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@@ -129,6 +139,7 @@ MIDDLEWARE = (
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.LoginRequiredMiddleware',
'utilities.middleware.APIVersionMiddleware',
) )
ROOT_URLCONF = 'netbox.urls' ROOT_URLCONF = 'netbox.urls'
@@ -183,12 +194,25 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
# Secrets # Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048 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 = { 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 # Django debug toolbar
INTERNAL_IPS = ( INTERNAL_IPS = (

View File

@@ -2,7 +2,7 @@ from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from django.contrib import admin 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 from users.views import login, logout
@@ -26,13 +26,14 @@ _patterns = [
url(r'^user/', include('users.urls', namespace='user')), url(r'^user/', include('users.urls', namespace='user')),
# API # API
url(r'^api/$', APIRootView.as_view(), name='api-root'),
url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), 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/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/ipam/', include('ipam.api.urls', namespace='ipam-api')),
url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-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/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
url(r'^api/docs/', include('rest_framework_swagger.urls')), url(r'^api/docs/', include('rest_framework_swagger.urls')),
url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
# Error testing # Error testing
url(r'^500/$', trigger_500), url(r'^500/$', trigger_500),

View File

@@ -1,5 +1,10 @@
import sys 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 django.shortcuts import render
from circuits.models import Provider, Circuit 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): def handle_500(request):
""" """
Custom server error handler Custom server error handler

View File

@@ -1,7 +1,7 @@
$(document).ready(function() { $(document).ready(function() {
// "Toggle all" checkbox (table header) // "Toggle all" checkbox (table header)
$('#toggle_all').click(function (event) { $('#toggle_all').click(function() {
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); $('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
if ($(this).is(':checked')) { if ($(this).is(':checked')) {
$('#select_all_box').removeClass('hidden'); $('#select_all_box').removeClass('hidden');
@@ -10,7 +10,7 @@ $(document).ready(function() {
} }
}); });
// Enable hidden buttons when "select all" is checked // Enable hidden buttons when "select all" is checked
$('#select_all').click(function (event) { $('#select_all').click(function() {
if ($(this).is(':checked')) { if ($(this).is(':checked')) {
$('#select_all_box').find('button').prop('disabled', ''); $('#select_all_box').find('button').prop('disabled', '');
} else { } else {
@@ -25,7 +25,7 @@ $(document).ready(function() {
}); });
// Simple "Toggle all" button (panel) // Simple "Toggle all" button (panel)
$('button.toggle').click(function (event) { $('button.toggle').click(function() {
var selected = $(this).attr('selected'); var selected = $(this).attr('selected');
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected); $(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
$(this).attr('selected', !selected); $(this).attr('selected', !selected);
@@ -55,12 +55,12 @@ $(document).ready(function() {
} }
// Bulk edit nullification // Bulk edit nullification
$('input:checkbox[name=_nullify]').click(function (event) { $('input:checkbox[name=_nullify]').click(function() {
$('#id_' + this.value).toggle('disabled'); $('#id_' + this.value).toggle('disabled');
}); });
// Set formaction and submit using a link // Set formaction and submit using a link
$('a.formaction').click(function (event) { $('a.formaction').click(function(event) {
event.preventDefault(); event.preventDefault();
var form = $(this).closest('form'); var form = $(this).closest('form');
form.attr('action', $(this).attr('href')); form.attr('action', $(this).attr('href'));
@@ -103,8 +103,8 @@ $(document).ready(function() {
$.ajax({ $.ajax({
url: api_url, url: api_url,
dataType: 'json', dataType: 'json',
success: function (response, status) { success: function(response, status) {
$.each(response, function (index, choice) { $.each(response.results, function(index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]); var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) { if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
option.attr("disabled", "disabled"); option.attr("disabled", "disabled");

View File

@@ -1,54 +1,94 @@
$(document).ready(function() { $(document).ready(function() {
// Unlocking a secret // Unlocking a secret
$('button.unlock-secret').click(function (event) { $('button.unlock-secret').click(function() {
var secret_id = $(this).attr('secret-id'); var secret_id = $(this).attr('secret-id');
unlock_secret(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);
}
}); });
// Locking a secret // Locking a secret
$('button.lock-secret').click(function (event) { $('button.lock-secret').click(function() {
var secret_id = $(this).attr('secret-id'); 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); var secret_div = $('#secret_' + secret_id);
// Delete the plaintext
secret_div.html('********'); secret_div.html('********');
$(this).hide(); $('button.lock-secret[secret-id=' + secret_id + ']').hide();
$(this).siblings('button.unlock-secret').show(); $('button.unlock-secret[secret-id=' + secret_id + ']').show();
}); }
// Adding/editing a secret // Request a session key via the API
private_key_field = $('#id_private_key'); function get_session_key(private_key) {
private_key_field.parents('form').submit(function(event) { var csrf_token = $('input[name=csrfmiddlewaretoken]').val();
console.log("form submitted"); $.ajax({
var private_key = sessionStorage.getItem('private_key'); url: netbox_api_path + 'secrets/get-session-key/',
if (private_key) { type: 'POST',
private_key_field.val(private_key); data: {
} else if ($('form .requires-private-key:first').val()) { private_key: private_key
console.log("we need a key!"); },
$('#privkey_modal').modal('show'); dataType: 'json',
return false; beforeSend: function(xhr, settings) {
} xhr.setRequestHeader("X-CSRFToken", csrf_token);
}); },
success: function (response, status) {
// Saving a private RSA key locally console.log("Received a new session key");
$('#submit_privkey').click(function() { alert('Session key received! You may now unlock secrets.');
var private_key = $('#user_privkey').val(); },
sessionStorage.setItem('private_key', private_key); 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 a new public/private key pair via the API
$('#generate_keypair').click(function() { $('#generate_keypair').click(function() {
$('#new_keypair_modal').modal('show'); $('#new_keypair_modal').modal('show');
$.ajax({ $.ajax({
url: netbox_api_path + 'secrets/generate-keys/', url: netbox_api_path + 'secrets/generate-rsa-key-pair/',
type: 'GET', type: 'GET',
dataType: 'json', dataType: 'json',
success: function (response, status) { 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() { $('#use_new_pubkey').click(function() {
var new_pubkey = $('#new_pubkey'); var new_pubkey = $('#new_pubkey');
if (new_pubkey.val()) { if (new_pubkey.val()) {
$('#id_public_key').val(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']);
}
}
});
}
}); });

View File

@@ -1,7 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from dcim.models import Device from dcim.api.serializers import NestedDeviceSerializer
from ipam.api.serializers import IPAddressNestedSerializer
from secrets.models import Secret, SecretRole from secrets.models import Secret, SecretRole
@@ -16,34 +16,41 @@ class SecretRoleSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class SecretRoleNestedSerializer(SecretRoleSerializer): class NestedSecretRoleSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
class Meta(SecretRoleSerializer.Meta): class Meta:
pass model = SecretRole
fields = ['id', 'url', 'name', 'slug']
# #
# Secrets # Secrets
# #
class SecretDeviceSerializer(serializers.ModelSerializer):
primary_ip = IPAddressNestedSerializer()
class Meta:
model = Device
fields = ['id', 'name', 'primary_ip']
class SecretSerializer(serializers.ModelSerializer): class SecretSerializer(serializers.ModelSerializer):
device = SecretDeviceSerializer() device = NestedDeviceSerializer()
role = SecretRoleNestedSerializer() role = NestedSecretRoleSerializer()
class Meta: class Meta:
model = Secret model = Secret
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated'] fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
class SecretNestedSerializer(SecretSerializer): class WritableSecretSerializer(serializers.ModelSerializer):
plaintext = serializers.CharField()
class Meta(SecretSerializer.Meta): class Meta:
fields = ['id', 'name'] 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

View File

@@ -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 router = routers.DefaultRouter()
url(r'^secret-roles/$', SecretRoleListView.as_view(), name='secretrole_list'), router.APIRootView = SecretsRootView
url(r'^secret-roles/(?P<pk>\d+)/$', SecretRoleDetailView.as_view(), name='secretrole_detail'),
# Miscellaneous # Secrets
url(r'^generate-keys/$', RSAKeyGeneratorView.as_view(), name='generate_keys'), 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

View File

@@ -1,142 +1,202 @@
import base64
from Crypto.PublicKey import RSA 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.exceptions import ValidationError
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response 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.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 from . import serializers
ERR_USERKEY_MISSING = "No UserKey found for the current user." ERR_USERKEY_MISSING = "No UserKey found for the current user."
ERR_USERKEY_INACTIVE = "UserKey has not been activated for decryption." ERR_USERKEY_INACTIVE = "UserKey has not been activated for decryption."
ERR_PRIVKEY_MISSING = "Private key was not provided."
ERR_PRIVKEY_INVALID = "Invalid private key." ERR_PRIVKEY_INVALID = "Invalid private key."
class SecretRoleListView(generics.ListAPIView): #
""" # Secret Roles
List all secret roles #
"""
class SecretRoleViewSet(ModelViewSet):
queryset = SecretRole.objects.all() queryset = SecretRole.objects.all()
serializer_class = serializers.SecretRoleSerializer serializer_class = serializers.SecretRoleSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
class SecretRoleDetailView(generics.RetrieveAPIView): #
""" # Secrets
Retrieve a single secret role #
"""
queryset = SecretRole.objects.all()
serializer_class = serializers.SecretRoleSerializer
permission_classes = [IsAuthenticated]
class SecretViewSet(WritableSerializerMixin, ModelViewSet):
class SecretListView(generics.GenericAPIView): queryset = Secret.objects.select_related(
""" 'device__primary_ip4', 'device__primary_ip6', 'role',
List secrets (filterable). If a private key is POSTed, attempt to decrypt each Secret. ).prefetch_related(
""" 'role__users', 'role__groups',
queryset = Secret.objects.select_related('device__primary_ip4', 'device__primary_ip6', 'role')\ )
.prefetch_related('role__users', 'role__groups')
serializer_class = serializers.SecretSerializer serializer_class = serializers.SecretSerializer
write_serializer_class = serializers.WritableSecretSerializer
filter_class = SecretFilter filter_class = SecretFilter
renderer_classes = [FormlessBrowsableAPIRenderer, JSONRenderer, FreeRADIUSClientsRenderer]
permission_classes = [IsAuthenticated]
def get(self, request, private_key=None): master_key = None
queryset = self.filter_queryset(self.get_queryset())
# Attempt to decrypt each Secret if a private key was provided. def _get_encrypted_fields(self, serializer):
if private_key: """
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: try:
uk = UserKey.objects.get(user=request.user) sk = SessionKey.objects.get(userkey__user=request.user)
except UserKey.DoesNotExist: self.master_key = sk.get_master_key(session_key)
return Response( except (SessionKey.DoesNotExist, InvalidSessionKey):
{'error': ERR_USERKEY_MISSING}, raise ValidationError("Invalid session key.")
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
)
serializer = self.get_serializer(queryset, many=True) def retrieve(self, request, *args, **kwargs):
return Response(serializer.data)
def post(self, request): secret = self.get_object()
return self.get(request, private_key=request.POST.get('private_key'))
# Attempt to decrypt the secret if the master key is known
class SecretDetailView(generics.GenericAPIView): if self.master_key is not None:
""" secret.decrypt(self.master_key)
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)
serializer = self.get_serializer(secret) serializer = self.get_serializer(secret)
return Response(serializer.data) return Response(serializer.data)
def post(self, request, pk): def list(self, request, *args, **kwargs):
return self.get(request, pk, private_key=request.POST.get('private_key'))
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] 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 # Determine what size key to generate
key_size = request.GET.get('key_size', 2048) key_size = request.GET.get('key_size', 2048)

View File

@@ -0,0 +1,5 @@
class InvalidSessionKey(Exception):
"""
Raised when the a provided session key is invalid.
"""
pass

View File

@@ -4,9 +4,11 @@ from django.db.models import Q
from .models import Secret, SecretRole from .models import Secret, SecretRole
from dcim.models import Device from dcim.models import Device
from utilities.filters import NumericInFilter
class SecretFilter(django_filters.FilterSet): class SecretFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@@ -22,11 +24,16 @@ class SecretFilter(django_filters.FilterSet):
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter( device = django_filters.ModelMultipleChoiceFilter(
name='device', name='device',
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
label='Device (Name)', label='Device (name)',
) )
class Meta: class Meta:

View File

@@ -47,9 +47,8 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
# #
class SecretForm(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', 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)', plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
widget=forms.PasswordInput()) widget=forms.PasswordInput())
@@ -59,9 +58,6 @@ class SecretForm(BootstrapMixin, forms.ModelForm):
def clean(self): 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']: if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
raise forms.ValidationError({ raise forms.ValidationError({
'plaintext2': "The two given plaintext values do not match. Please check your input." '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): class SecretImportForm(BootstrapMixin, BulkImportForm):
private_key = forms.CharField(widget=forms.HiddenInput()) csv = CSVDataField(csv_form=SecretFromCSVForm)
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'}))
class SecretBulkEditForm(BootstrapMixin, BulkEditForm): class SecretBulkEditForm(BootstrapMixin, BulkEditForm):

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

View File

@@ -1,5 +1,5 @@
import os import os
from Crypto.Cipher import AES, PKCS1_OAEP from Crypto.Cipher import AES, PKCS1_OAEP, XOR
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from django.conf import settings 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 dcim.models import Device
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidSessionKey
from .hashers import SecretValidationHasher 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): 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) 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): class UserKeyQuerySet(models.QuerySet):
def active(self): 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 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. 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') public_key = models.TextField(verbose_name='RSA public key')
master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False) 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 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(): 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) self.master_key_cipher = encrypt_master_key(master_key, self.public_key)
super(UserKey, self).save(*args, **kwargs) super(UserKey, self).save(*args, **kwargs)
@@ -171,6 +182,53 @@ class UserKey(CreatedUpdatedModel):
self.save() 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 @python_2_unicode_compatible
class SecretRole(models.Model): class SecretRole(models.Model):
""" """
@@ -303,9 +361,10 @@ class Secret(CreatedUpdatedModel):
raise Exception("Must define ciphertext before unlocking.") raise Exception("Must define ciphertext before unlocking.")
# Decrypt ciphertext and remove padding # 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) 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 # Verify decrypted plaintext against hash
if not self.validate(plaintext): if not self.validate(plaintext):

View 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)

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase 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 from secrets.hashers import SecretValidationHasher
@@ -33,7 +33,7 @@ class UserKeyTestCase(TestCase):
""" """
Validate the activation of a UserKey. 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']) 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") self.assertFalse(alice_uk.is_active(), "Inactive UserKey is_active() did not return False")
alice_uk.activate(master_key) 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. 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 = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public'])
alice_uk.activate(master_key) alice_uk.activate(master_key)
retrieved_master_key = alice_uk.get_master_key(self.TEST_KEYS['alice_private']) 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. 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']) secret_key_cipher = encrypt_master_key(secret_key, self.TEST_KEYS['alice_public'])
try: try:
decrypted_secret_key = decrypt_master_key(secret_key_cipher, self.TEST_KEYS['bob_private']) 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. Test basic encryption and decryption functionality using a random master key.
""" """
plaintext = "FooBar123" plaintext = "FooBar123"
secret_key = generate_master_key() secret_key = generate_random_key()
s = Secret(plaintext=plaintext) s = Secret(plaintext=plaintext)
s.encrypt(secret_key) 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. Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
""" """
plaintext = "1234567890abcdef" plaintext = "1234567890abcdef"
secret_key = generate_master_key() secret_key = generate_random_key()
ivs = [] ivs = []
ciphertexts = [] ciphertexts = []
for i in range(1, 51): for i in range(1, 51):

View File

@@ -1,3 +1,5 @@
import base64
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required, login_required from django.contrib.auth.decorators import permission_required, login_required
from django.contrib.auth.mixins import PermissionRequiredMixin 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 . import filters, forms, tables
from .decorators import userkey_required from .decorators import userkey_required
from .models import SecretRole, Secret, UserKey from .models import SecretRole, Secret, SessionKey, UserKey
# #
@@ -77,23 +79,30 @@ def secret_add(request, pk):
form = forms.SecretForm(request.POST, instance=secret) form = forms.SecretForm(request.POST, instance=secret)
if form.is_valid(): if form.is_valid():
# Retrieve the master key from the current user's UserKey # We need a valid session key in order to create a Secret
master_key = uk.get_master_key(form.cleaned_data['private_key']) session_key = base64.b64decode(request.COOKIES.get('session_key', None))
if master_key is None: if session_key is None:
form.add_error(None, "Invalid private key! Unable to encrypt secret data.") form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
# Create and encrypt the new Secret # Create and encrypt the new Secret
else: else:
secret = form.save(commit=False) master_key = None
secret.plaintext = str(form.cleaned_data['plaintext']) try:
secret.encrypt(master_key) sk = SessionKey.objects.get(userkey__user=request.user)
secret.save() 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 master_key is not None:
if '_addanother' in request.POST: secret = form.save(commit=False)
return redirect('dcim:device_addsecret', pk=device.pk) secret.plaintext = str(form.cleaned_data['plaintext'])
else: secret.encrypt(master_key)
return redirect('secrets:secret', pk=secret.pk) 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: else:
form = forms.SecretForm(instance=secret) form = forms.SecretForm(instance=secret)
@@ -110,32 +119,43 @@ def secret_add(request, pk):
def secret_edit(request, pk): def secret_edit(request, pk):
secret = get_object_or_404(Secret, pk=pk) secret = get_object_or_404(Secret, pk=pk)
uk = UserKey.objects.get(user=request.user)
if request.method == 'POST': if request.method == 'POST':
form = forms.SecretForm(request.POST, instance=secret) form = forms.SecretForm(request.POST, instance=secret)
if form.is_valid(): if form.is_valid():
# Re-encrypt the Secret if a plaintext has been specified. # Re-encrypt the Secret if a plaintext and session key have been provided.
if form.cleaned_data['plaintext']: 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 # Retrieve the master key using the provided session key
master_key = uk.get_master_key(form.cleaned_data['private_key']) master_key = None
if master_key is None: try:
form.add_error(None, "Invalid private key! Unable to encrypt secret data.") 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 # Create and encrypt the new Secret
else: if master_key is not None:
secret = form.save(commit=False) secret = form.save(commit=False)
secret.plaintext = str(form.cleaned_data['plaintext']) secret.plaintext = str(form.cleaned_data['plaintext'])
secret.encrypt(master_key) secret.encrypt(master_key)
secret.save() 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: else:
secret = form.save() secret = form.save()
messages.success(request, u"Modified secret {}.".format(secret))
messages.success(request, u"Modified secret {}.".format(secret)) return redirect('secrets:secret', pk=secret.pk)
return redirect('secrets:secret', pk=secret.pk)
else: else:
form = forms.SecretForm(instance=secret) form = forms.SecretForm(instance=secret)
@@ -157,19 +177,28 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@userkey_required() @userkey_required()
def secret_import(request): def secret_import(request):
uk = UserKey.objects.get(user=request.user) session_key = request.COOKIES.get('session_key', None)
if request.method == 'POST': if request.method == 'POST':
form = forms.SecretImportForm(request.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(): if form.is_valid():
new_secrets = [] new_secrets = []
# Retrieve the master key from the current user's UserKey session_key = base64.b64decode(session_key)
master_key = uk.get_master_key(form.cleaned_data['private_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: if master_key is None:
form.add_error(None, "Invalid private key! Unable to encrypt secret data.") form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
else: else:
try: try:
with transaction.atomic(): with transaction.atomic():

View File

@@ -295,7 +295,8 @@
<p class="text-muted"> <p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot; <i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot; <i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot;
<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-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> &middot;
<i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -27,7 +27,7 @@
</div> </div>
<div class="pull-right"> <div class="pull-right">
{% if show_graphs %} {% 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> <i class="fa fa-signal" aria-hidden="true"></i>
Graphs Graphs
</button> </button>

View File

@@ -46,7 +46,7 @@
<table class="table table-hover table-condensed panel-body" id="hardware"> <table class="table table-hover table-condensed panel-body" id="hardware">
<thead> <thead>
<tr> <tr>
<th>Module</th> <th>Name</th>
<th></th> <th></th>
<th>Manufacturer</th> <th>Manufacturer</th>
<th>Part Number</th> <th>Part Number</th>
@@ -55,81 +55,18 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for m in modules %} {% for item in inventory_items %}
<tr> {% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
<td>{{ m.name }}</td> {% include template_name %}
<td>{% if not m.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td> {% endwith %}
<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 %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if perms.dcim.add_module %} {% if perms.dcim.add_inventoryitem %}
<a href="{% url 'dcim:module_add' device=device.pk %}" class="btn btn-success"> <a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-success">
<span class="fa fa-plus" aria-hidden="true"></span> <span class="fa fa-plus" aria-hidden="true"></span>
Add a Module Add Inventory Item
</a> </a>
{% endif %} {% endif %}
</div> </div>

View File

@@ -47,7 +47,7 @@
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { $(document).ready(function() {
$.ajax({ $.ajax({
url: "{% url 'dcim-api:device_lldp-neighbors' pk=device.pk %}", url: "{% url 'dcim-api:device-lldp-neighbors' pk=device.pk %}",
dataType: 'json', dataType: 'json',
success: function(json) { success: function(json) {
$.each(json, function(i, neighbor) { $.each(json, function(i, neighbor) {
@@ -66,6 +66,9 @@ $(document).ready(function() {
row.addClass('danger'); row.addClass('danger');
} }
}); });
},
error: function(xhr) {
alert(xhr.responseText);
} }
}); });
}); });

View File

@@ -48,7 +48,7 @@
<td class="text-right"> <td class="text-right">
{% if show_graphs %} {% if show_graphs %}
{% if iface.circuit_termination or iface.connection %} {% 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> <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
</button> </button>
{% endif %} {% endif %}

View 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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -33,7 +33,7 @@
</div> </div>
<div class="pull-right"> <div class="pull-right">
{% if show_graphs %} {% 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> <i class="fa fa-signal" aria-hidden="true"></i>
Graphs Graphs
</button> </button>

View File

@@ -10,16 +10,15 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p> <p>
Your private RSA key is needed to complete this action. Once you've provided your key, it will You do not have an active session key. To request one, please provide your private RSA key below.
remain cached locally until you close this browser tab. Once retrieved, your session key will be saved for future requests.
</p> </p>
<div class="form-group"> <div class="form-group">
<textarea class="form-control" id="user_privkey" style="height: 300px;"></textarea> <textarea class="form-control" id="user_privkey" style="height: 300px;"></textarea>
</div> </div>
<div class="form-group text-right"> <div class="form-group text-right">
<button id="submit_privkey" class="btn btn-primary unlock-secret" data-dismiss="modal"> <button id="request_session_key" class="btn btn-primary" data-dismiss="modal">
<i class="fa fa-save" aria-hidden="True"></i> Request session key
Save RSA Key
</button> </button>
</div> </div>
</div> </div>

View File

@@ -9,10 +9,21 @@
<div class="row"> <div class="row">
<div class="col-sm-3 col-md-2 col-md-offset-2"> <div class="col-sm-3 col-md-2 col-md-offset-2">
<ul class="nav nav-pills nav-stacked"> <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 "profile" %} class="active"{% endifequal %}>
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}><a href="{% url 'user:change_password' %}">Change Password</a></li> <a href="{% url 'user:profile' %}">Profile</a>
<li{% ifequal active_tab "userkey" %} class="active"{% endifequal %}><a href="{% url 'user:userkey' %}">User Key</a></li> </li>
<li{% ifequal active_tab "recent_activity" %} class="active"{% endifequal %}><a href="{% url 'user:recent_activity' %}">Recent Activity</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> </ul>
</div> </div>
<div class="col-sm-9 col-md-6"> <div class="col-sm-9 col-md-6">

View 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 %}

View 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 %}

View File

@@ -4,6 +4,12 @@
{% block usercontent %} {% block usercontent %}
{% if userkey %} {% 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> <h4>
Your user key is: Your user key is:
{% if userkey.is_active %} {% if userkey.is_active %}
@@ -12,15 +18,21 @@
<span class="label label-danger">Inactive</span> <span class="label label-danger">Inactive</span>
{% endif %} {% endif %}
</h4> </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 %} {% 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 %} {% else %}
<p>You don't have a user key on file.</p> <p>You don't have a user key on file.</p>
<p> <p>

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from extras.api.serializers import CustomFieldSerializer from extras.api.customfields import CustomFieldModelSerializer
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@@ -15,25 +15,36 @@ class TenantGroupSerializer(serializers.ModelSerializer):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class TenantGroupNestedSerializer(TenantGroupSerializer): class NestedTenantGroupSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
class Meta(TenantGroupSerializer.Meta): class Meta:
pass model = TenantGroup
fields = ['id', 'url', 'name', 'slug']
# #
# Tenants # Tenants
# #
class TenantSerializer(CustomFieldSerializer, serializers.ModelSerializer): class TenantSerializer(CustomFieldModelSerializer):
group = TenantGroupNestedSerializer() group = NestedTenantGroupSerializer()
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields'] fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
class TenantNestedSerializer(TenantSerializer): class NestedTenantSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
class Meta(TenantSerializer.Meta): class Meta:
fields = ['id', 'name', 'slug'] model = Tenant
fields = ['id', 'url', 'name', 'slug']
class WritableTenantSerializer(serializers.ModelSerializer):
class Meta:
model = Tenant
fields = ['id', 'name', 'slug', 'group', 'description', 'comments']

View File

@@ -1,16 +1,21 @@
from django.conf.urls import url from rest_framework import routers
from .views import * from . import views
urlpatterns = [ class TenancyRootView(routers.APIRootView):
"""
Tenancy API root view
"""
def get_view_name(self):
return 'Tenancy'
# Tenant groups
url(r'^tenant-groups/$', TenantGroupListView.as_view(), name='tenantgroup_list'),
url(r'^tenant-groups/(?P<pk>\d+)/$', TenantGroupDetailView.as_view(), name='tenantgroup_detail'),
# Tenants router = routers.DefaultRouter()
url(r'^tenants/$', TenantListView.as_view(), name='tenant_list'), router.APIRootView = TenancyRootView
url(r'^tenants/(?P<pk>\d+)/$', TenantDetailView.as_view(), name='tenant_detail'),
] # Tenants
router.register(r'tenant-groups', views.TenantGroupViewSet)
router.register(r'tenants', views.TenantViewSet)
urlpatterns = router.urls

View File

@@ -1,40 +1,28 @@
from rest_framework import generics from rest_framework.viewsets import ModelViewSet
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from tenancy.filters import TenantFilter from tenancy.filters import TenantFilter
from extras.api.views import CustomFieldModelAPIView from extras.api.views import CustomFieldModelViewSet
from utilities.api import WritableSerializerMixin
from . import serializers from . import serializers
class TenantGroupListView(generics.ListAPIView): #
""" # Tenant Groups
List all tenant groups #
"""
class TenantGroupViewSet(ModelViewSet):
queryset = TenantGroup.objects.all() queryset = TenantGroup.objects.all()
serializer_class = serializers.TenantGroupSerializer serializer_class = serializers.TenantGroupSerializer
class TenantGroupDetailView(generics.RetrieveAPIView): #
""" # Tenants
Retrieve a single circuit type #
"""
queryset = TenantGroup.objects.all()
serializer_class = serializers.TenantGroupSerializer
class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
class TenantListView(CustomFieldModelAPIView, generics.ListAPIView): queryset = Tenant.objects.select_related('group')
"""
List tenants (filterable)
"""
queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field')
serializer_class = serializers.TenantSerializer serializer_class = serializers.TenantSerializer
write_serializer_class = serializers.WritableTenantSerializer
filter_class = TenantFilter filter_class = TenantFilter
class TenantDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
"""
Retrieve a single tenant
"""
queryset = Tenant.objects.select_related('group').prefetch_related('custom_field_values__field')
serializer_class = serializers.TenantSerializer

View File

@@ -3,11 +3,12 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from utilities.filters import NullableModelMultipleChoiceFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

View File

@@ -0,0 +1,149 @@
from rest_framework import status
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from django.urls import reverse
from tenancy.models import Tenant, TenantGroup
from users.models import Token
from utilities.tests import HttpStatusMixin
class TenantGroupTest(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.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
self.tenantgroup3 = TenantGroup.objects.create(name='Test Tenant Group 3', slug='test-tenant-group-3')
def test_get_tenantgroup(self):
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.tenantgroup1.name)
def test_list_tenantgroups(self):
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_tenantgroup(self):
data = {
'name': 'Test Tenant Group 4',
'slug': 'test-tenant-group-4',
}
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(TenantGroup.objects.count(), 4)
tenantgroup4 = TenantGroup.objects.get(pk=response.data['id'])
self.assertEqual(tenantgroup4.name, data['name'])
self.assertEqual(tenantgroup4.slug, data['slug'])
def test_update_tenantgroup(self):
data = {
'name': 'Test Tenant Group X',
'slug': 'test-tenant-group-x',
}
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(TenantGroup.objects.count(), 3)
tenantgroup1 = TenantGroup.objects.get(pk=response.data['id'])
self.assertEqual(tenantgroup1.name, data['name'])
self.assertEqual(tenantgroup1.slug, data['slug'])
def test_delete_tenantgroup(self):
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(TenantGroup.objects.count(), 2)
class TenantTest(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.tenantgroup1 = TenantGroup.objects.create(name='Test Tenant Group 1', slug='test-tenant-group-1')
self.tenantgroup2 = TenantGroup.objects.create(name='Test Tenant Group 2', slug='test-tenant-group-2')
self.tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1', group=self.tenantgroup1)
self.tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2', group=self.tenantgroup1)
self.tenant3 = Tenant.objects.create(name='Test Tenant 3', slug='test-tenant-3', group=self.tenantgroup1)
def test_get_tenant(self):
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.tenant1.name)
def test_list_tenants(self):
url = reverse('tenancy-api:tenant-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_tenant(self):
data = {
'name': 'Test Tenant 4',
'slug': 'test-tenant-4',
'group': self.tenantgroup1.pk,
}
url = reverse('tenancy-api:tenant-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tenant.objects.count(), 4)
tenant4 = Tenant.objects.get(pk=response.data['id'])
self.assertEqual(tenant4.name, data['name'])
self.assertEqual(tenant4.slug, data['slug'])
self.assertEqual(tenant4.group_id, data['group'])
def test_update_tenant(self):
data = {
'name': 'Test Tenant X',
'slug': 'test-tenant-x',
'group': self.tenantgroup2.pk,
}
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Tenant.objects.count(), 3)
tenant1 = Tenant.objects.get(pk=response.data['id'])
self.assertEqual(tenant1.name, data['name'])
self.assertEqual(tenant1.slug, data['slug'])
self.assertEqual(tenant1.group_id, data['group'])
def test_delete_tenant(self):
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Tenant.objects.count(), 2)

8
netbox/users/admin.py Normal file
View File

@@ -0,0 +1,8 @@
from django.contrib import admin
from .models import Token
@admin.register(Token)
class TokenAdmin(admin.ModelAdmin):
list_display = ['user', 'key', 'created', 'expires', 'write_enabled', 'description']

View File

View File

@@ -0,0 +1,10 @@
from django.contrib.auth.models import User
from rest_framework import serializers
class NestedUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username']

View File

@@ -1,6 +1,8 @@
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
from django import forms
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin
from .models import Token
class LoginForm(BootstrapMixin, AuthenticationForm): class LoginForm(BootstrapMixin, AuthenticationForm):
@@ -14,3 +16,14 @@ class LoginForm(BootstrapMixin, AuthenticationForm):
class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm):
pass pass
class TokenForm(BootstrapMixin, forms.ModelForm):
key = forms.CharField(required=False, help_text="If no key is provided, one will be generated automatically.")
class Meta:
model = Token
fields = ['key', 'write_enabled', 'expires', 'description']
help_texts = {
'expires': 'YYYY-MM-DD [HH:MM:SS]'
}

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.6 on 2017-03-08 15:32
from __future__ import unicode_literals
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Token',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True)),
('expires', models.DateTimeField(blank=True, null=True)),
('key', models.CharField(max_length=40, unique=True, validators=[django.core.validators.MinLengthValidator(40)])),
('write_enabled', models.BooleanField(default=True, help_text=b'Permit create/update/delete operations using this key')),
('description', models.CharField(blank=True, max_length=100)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'default_permissions': [],
},
),
]

View File

View File

@@ -0,0 +1,44 @@
import binascii
import os
from django.contrib.auth.models import User
from django.core.validators import MinLengthValidator
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils import timezone
@python_2_unicode_compatible
class Token(models.Model):
"""
An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens.
It also supports setting an expiration time and toggling write ability.
"""
user = models.ForeignKey(User, related_name='tokens', on_delete=models.CASCADE)
created = models.DateTimeField(auto_now_add=True)
expires = models.DateTimeField(blank=True, null=True)
key = models.CharField(max_length=40, unique=True, validators=[MinLengthValidator(40)])
write_enabled = models.BooleanField(default=True, help_text="Permit create/update/delete operations using this key")
description = models.CharField(max_length=100, blank=True)
class Meta:
default_permissions = []
def __str__(self):
# Only display the last 24 bits of the token to avoid accidental exposure.
return u"{} ({})".format(self.key[-6:], self.user)
def save(self, *args, **kwargs):
if not self.key:
self.key = self.generate_key()
return super(Token, self).save(*args, **kwargs)
def generate_key(self):
# Generate a random 160-bit key expressed in hexadecimal.
return binascii.hexlify(os.urandom(20)).decode()
@property
def is_expired(self):
if self.expires is not None and timezone.now() > self.expires:
return True
return False

View File

@@ -5,11 +5,15 @@ from . import views
urlpatterns = [ urlpatterns = [
# User profiles
url(r'^profile/$', views.profile, name='profile'), url(r'^profile/$', views.profile, name='profile'),
url(r'^password/$', views.change_password, name='change_password'), url(r'^password/$', views.change_password, name='change_password'),
url(r'^api-tokens/$', views.TokenListView.as_view(), name='token_list'),
url(r'^api-tokens/add/$', views.TokenEditView.as_view(), name='token_add'),
url(r'^api-tokens/(?P<pk>\d+)/edit/$', views.TokenEditView.as_view(), name='token_edit'),
url(r'^api-tokens/(?P<pk>\d+)/delete/$', views.TokenDeleteView.as_view(), name='token_delete'),
url(r'^user-key/$', views.userkey, name='userkey'), url(r'^user-key/$', views.userkey, name='userkey'),
url(r'^user-key/edit/$', views.userkey_edit, name='userkey_edit'), url(r'^user-key/edit/$', views.userkey_edit, name='userkey_edit'),
url(r'^session-key/delete/$', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'),
url(r'^recent-activity/$', views.recent_activity, name='recent_activity'), url(r'^recent-activity/$', views.recent_activity, name='recent_activity'),
] ]

View File

@@ -1,15 +1,18 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import is_safe_url from django.utils.http import is_safe_url
from django.views.generic import View
from secrets.forms import UserKeyForm from secrets.forms import UserKeyForm
from secrets.models import UserKey from secrets.models import SessionKey, UserKey
from utilities.forms import ConfirmationForm
from .forms import LoginForm, PasswordChangeForm from .forms import LoginForm, PasswordChangeForm, TokenForm
from .models import Token
# #
@@ -121,6 +124,42 @@ def userkey_edit(request):
}) })
class SessionKeyDeleteView(LoginRequiredMixin, View):
def get(self, request):
sessionkey = get_object_or_404(SessionKey, userkey__user=request.user)
form = ConfirmationForm()
return render(request, 'users/sessionkey_delete.html', {
'obj_type': sessionkey._meta.verbose_name,
'form': form,
'return_url': reverse('user:userkey'),
})
def post(self, request):
sessionkey = get_object_or_404(SessionKey, userkey__user=request.user)
form = ConfirmationForm(request.POST)
if form.is_valid():
# Delete session key
sessionkey.delete()
messages.success(request, "Session key deleted")
# Delete cookie
response = redirect('user:userkey')
response.delete_cookie('session_key')
return response
return render(request, 'users/sessionkey_delete.html', {
'obj_type': sessionkey._meta.verbose_name,
'form': form,
'return_url': reverse('user:userkey'),
})
@login_required() @login_required()
def recent_activity(request): def recent_activity(request):
@@ -128,3 +167,90 @@ def recent_activity(request):
'recent_activity': request.user.actions.all()[:50], 'recent_activity': request.user.actions.all()[:50],
'active_tab': 'recent_activity', 'active_tab': 'recent_activity',
}) })
#
# API tokens
#
class TokenListView(LoginRequiredMixin, View):
def get(self, request):
tokens = Token.objects.filter(user=request.user)
return render(request, 'users/api_tokens.html', {
'tokens': tokens,
'active_tab': 'api_tokens',
})
class TokenEditView(LoginRequiredMixin, View):
def get(self, request, pk=None):
if pk is not None:
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
else:
token = Token(user=request.user)
form = TokenForm(instance=token)
return render(request, 'utilities/obj_edit.html', {
'obj': token,
'obj_type': token._meta.verbose_name,
'form': form,
'return_url': reverse('user:token_list'),
})
def post(self, request, pk=None):
if pk is not None:
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
form = TokenForm(request.POST, instance=token)
else:
form = TokenForm(request.POST)
if form.is_valid():
token = form.save(commit=False)
token.user = request.user
token.save()
msg = "Token updated" if pk else "New token created"
messages.success(request, msg)
return redirect('user:token_list')
class TokenDeleteView(LoginRequiredMixin, View):
def get(self, request, pk):
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
initial_data = {
'return_url': reverse('user:token_list'),
}
form = ConfirmationForm(initial=initial_data)
return render(request, 'utilities/obj_delete.html', {
'obj': token,
'obj_type': token._meta.verbose_name,
'form': form,
'return_url': reverse('user:token_list'),
})
def post(self, request, pk):
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
form = ConfirmationForm(request.POST)
if form.is_valid():
token.delete()
messages.success(request, "Token deleted")
return redirect('user:token_list')
return render(request, 'utilities/obj_delete.html', {
'obj': token,
'obj_type': token._meta.verbose_name,
'form': form,
'return_url': reverse('user:token_list'),
})

View File

@@ -1,6 +1,89 @@
from django.conf import settings
from rest_framework import authentication, exceptions
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
from rest_framework.serializers import Field
from users.models import Token
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
class ServiceUnavailable(APIException): class ServiceUnavailable(APIException):
status_code = 503 status_code = 503
default_detail = "Service temporarily unavailable, please try again later." default_detail = "Service temporarily unavailable, please try again later."
class TokenAuthentication(authentication.TokenAuthentication):
"""
A custom authentication scheme which enforces Token expiration times.
"""
model = Token
def authenticate_credentials(self, key):
model = self.get_model()
try:
token = model.objects.select_related('user').get(key=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token")
# Enforce the Token's expiration time, if one has been set.
if token.expires and not token.is_expired:
raise exceptions.AuthenticationFailed("Token expired")
if not token.user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
return token.user, token
class TokenPermissions(DjangoModelPermissions):
"""
Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
for unsafe requests (POST/PUT/PATCH/DELETE).
"""
def __init__(self):
# LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
self.authenticated_users_only = settings.LOGIN_REQUIRED
super(TokenPermissions, self).__init__()
def has_permission(self, request, view):
# If token authentication is in use, verify that the token allows write operations (for unsafe methods).
if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
if not request.auth.write_enabled:
return False
return super(TokenPermissions, self).has_permission(request, view)
class ChoiceFieldSerializer(Field):
"""
Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
"""
def __init__(self, choices, **kwargs):
self._choices = dict()
for k, v in choices:
# Unpack grouped choices
if type(v) in [list, tuple]:
for k2, v2 in v:
self._choices[k2] = v2
else:
self._choices[k] = v
super(ChoiceFieldSerializer, self).__init__(**kwargs)
def to_representation(self, obj):
return {'value': obj, 'label': self._choices[obj]}
def to_internal_value(self, data):
return self._choices.get(data)
class WritableSerializerMixin(object):
"""
Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
"""
def get_serializer_class(self):
if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
return self.write_serializer_class
return self.serializer_class

View File

@@ -6,6 +6,17 @@ from django.db.models import Q
from django.utils.encoding import force_text from django.utils.encoding import force_text
#
# Filters
#
class NumericInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
"""
Filters for a set of numeric values. Example: id__in=100,200,300
"""
pass
class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField): class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField):
""" """
This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is

View File

@@ -1,5 +1,6 @@
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse
BASE_PATH = getattr(settings, 'BASE_PATH', False) BASE_PATH = getattr(settings, 'BASE_PATH', False)
@@ -17,7 +18,22 @@ class LoginRequiredMiddleware(object):
if LOGIN_REQUIRED and not request.user.is_authenticated(): if LOGIN_REQUIRED and not request.user.is_authenticated():
# Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API
# performs its own authentication. # performs its own authentication.
api_path = '/{}api/'.format(BASE_PATH) api_path = reverse('api-root')
if not request.path_info.startswith(api_path) and request.path_info != settings.LOGIN_URL: if not request.path_info.startswith(api_path) and request.path_info != settings.LOGIN_URL:
return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info)) return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info))
return self.get_response(request) return self.get_response(request)
class APIVersionMiddleware(object):
"""
If the request is for an API endpoint, include the API version as a response header.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
api_path = reverse('api-root')
response = self.get_response(request)
if request.path_info.startswith(api_path):
response['API-Version'] = settings.REST_FRAMEWORK_VERSION
return response

10
netbox/utilities/tests.py Normal file
View File

@@ -0,0 +1,10 @@
class HttpStatusMixin(object):
"""
Custom mixin to provide more detail in the event of an unexpected HTTP response.
"""
def assertHttpStatus(self, response, expected_status):
err_message = "Expected HTTP status {}; received {}: {}"
self.assertEqual(response.status_code, expected_status, err_message.format(
expected_status, response.status_code, response.data
))

View File

@@ -1,6 +1,7 @@
cffi>=1.8 cffi>=1.8
cryptography>=1.4 cryptography>=1.4
Django>=1.10 Django>=1.10
django-cors-headers>=2.0
django-debug-toolbar>=1.6 django-debug-toolbar>=1.6
django-filter>=1.0.1 django-filter>=1.0.1
django-mptt==0.8.7 django-mptt==0.8.7