Browse Source

Merging v2.0 development into mainline (#1145)

Merging v2.0 development into mainline
Jeremy Stretch 8 years ago
parent
commit
838105fb65
100 changed files with 6667 additions and 3468 deletions
  1. 0 20
      Dockerfile
  2. 1 1
      README.md
  3. 0 53
      docker-compose.yml
  4. 0 22
      docker/docker-entrypoint.sh
  5. 0 5
      docker/gunicorn_config.py
  6. 0 35
      docker/nginx.conf
  7. 0 19
      docs/api-integration.md
  8. 48 0
      docs/api/authentication.md
  9. 138 0
      docs/api/examples.md
  10. 138 0
      docs/api/overview.md
  11. 136 0
      docs/api/working-with-secrets.md
  12. 16 0
      docs/configuration/optional-settings.md
  13. 5 5
      docs/data-model/dcim.md
  14. 7 0
      docs/data-model/extras.md
  15. 0 51
      docs/installation/docker.md
  16. 5 2
      mkdocs.yml
  17. 0 29
      netbox/circuits/admin.py
  18. 72 27
      netbox/circuits/api/serializers.py
  19. 18 17
      netbox/circuits/api/urls.py
  20. 47 40
      netbox/circuits/api/views.py
  21. 13 2
      netbox/circuits/filters.py
  22. 2 2
      netbox/circuits/forms.py
  23. 1 1
      netbox/circuits/models.py
  24. 35 17
      netbox/circuits/tables.py
  25. 0 0
      netbox/circuits/tests/__init__.py
  26. 329 0
      netbox/circuits/tests/test_api.py
  27. 1 0
      netbox/circuits/urls.py
  28. 1 1
      netbox/circuits/views.py
  29. 0 212
      netbox/dcim/admin.py
  30. 364 176
      netbox/dcim/api/serializers.py
  31. 62 84
      netbox/dcim/api/urls.py
  32. 197 380
      netbox/dcim/api/views.py
  33. 116 59
      netbox/dcim/filters.py
  34. 50 48
      netbox/dcim/forms.py
  35. 21 0
      netbox/dcim/migrations/0033_rackreservation_rack_editable.py
  36. 35 0
      netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py
  37. 27 0
      netbox/dcim/migrations/0035_device_expand_status_choices.py
  38. 60 28
      netbox/dcim/models.py
  39. 84 32
      netbox/dcim/tables.py
  40. 2158 0
      netbox/dcim/tests/test_api.py
  41. 0 676
      netbox/dcim/tests/test_apis.py
  42. 12 5
      netbox/dcim/urls.py
  43. 55 14
      netbox/dcim/views.py
  44. 131 0
      netbox/extras/api/customfields.py
  45. 0 88
      netbox/extras/api/renderers.py
  46. 111 32
      netbox/extras/api/serializers.py
  47. 33 0
      netbox/extras/api/urls.py
  48. 63 83
      netbox/extras/api/views.py
  49. 47 1
      netbox/extras/filters.py
  50. 10 2
      netbox/extras/forms.py
  51. 14 14
      netbox/extras/management/commands/run_inventory.py
  52. 34 0
      netbox/extras/migrations/0006_add_imageattachments.py
  53. 147 7
      netbox/extras/models.py
  54. 27 24
      netbox/extras/rpc.py
  55. 168 0
      netbox/extras/tests/test_api.py
  56. 214 1
      netbox/extras/tests/test_customfields.py
  57. 13 0
      netbox/extras/urls.py
  58. 30 0
      netbox/extras/views.py
  59. 0 81
      netbox/ipam/admin.py
  60. 158 66
      netbox/ipam/api/serializers.py
  61. 28 31
      netbox/ipam/api/urls.py
  62. 26 123
      netbox/ipam/api/views.py
  63. 2 2
      netbox/ipam/forms.py
  64. 1 1
      netbox/ipam/models.py
  65. 86 36
      netbox/ipam/tables.py
  66. 660 0
      netbox/ipam/tests/test_api.py
  67. 1 0
      netbox/ipam/urls.py
  68. 1 1
      netbox/ipam/views.py
  69. 2 0
      netbox/media/image-attachments/.gitignore
  70. 29 18
      netbox/netbox/configuration.example.py
  71. 40 0
      netbox/netbox/forms.py
  72. 48 13
      netbox/netbox/settings.py
  73. 22 15
      netbox/netbox/urls.py
  74. 177 5
      netbox/netbox/views.py
  75. 0 1
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css.map
  76. 0 1
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css.map
  77. 0 6
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.min.css
  78. 0 1
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.min.css.map
  79. 0 7
      netbox/project-static/bootstrap-3.3.6-dist/js/bootstrap.min.js
  80. 2 2
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.css
  81. 1 1
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.css.map
  82. 2 2
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css
  83. 1 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.min.css.map
  84. 2 5
      netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css
  85. 1 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.css.map
  86. 6 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css
  87. 1 0
      netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css.map
  88. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.eot
  89. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.svg
  90. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.ttf
  91. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff
  92. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff2
  93. 64 50
      netbox/project-static/bootstrap-3.3.6-dist/js/bootstrap.js
  94. 7 0
      netbox/project-static/bootstrap-3.3.7-dist/js/bootstrap.min.js
  95. 0 0
      netbox/project-static/bootstrap-3.3.7-dist/js/npm.js
  96. 3 0
      netbox/project-static/css/base.css
  97. BIN
      netbox/project-static/font-awesome-4.6.3/fonts/FontAwesome.otf
  98. BIN
      netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.eot
  99. 0 685
      netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.svg
  100. 0 0
      netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.woff

+ 0 - 20
Dockerfile

@@ -1,20 +0,0 @@
-FROM python:2.7-wheezy
-
-WORKDIR /opt/netbox
-
-ARG BRANCH=master
-ARG URL=https://github.com/digitalocean/netbox.git
-RUN git clone --depth 1 $URL -b $BRANCH .  && \
-    apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev graphviz && \
-	pip install gunicorn==17.5 && \
-	pip install django-auth-ldap && \
-    pip install -r requirements.txt
-
-ADD docker/docker-entrypoint.sh /docker-entrypoint.sh
-ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py
-
-ENTRYPOINT [ "/docker-entrypoint.sh" ]
-
-ADD docker/gunicorn_config.py /opt/netbox/
-ADD docker/nginx.conf /etc/netbox-nginx/
-VOLUME ["/etc/netbox-nginx/"]

+ 1 - 1
README.md

@@ -31,5 +31,5 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
 
 ## Alternative Installations
 
-* [Docker container](http://netbox.readthedocs.io/en/stable/installation/docker/)
+* [Docker container](https://github.com/digitalocean/netbox-docker)
 * [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))

+ 0 - 53
docker-compose.yml

@@ -1,53 +0,0 @@
-version: '2'
-
-services:
-    postgres:
-        image: postgres:9.6
-        container_name: postgres
-        environment:
-            POSTGRES_USER: netbox
-            POSTGRES_PASSWORD: J5brHrAXFLQSif0K
-            POSTGRES_DB: netbox
-    netbox:
-        build: .
-        image: digitalocean/netbox
-        links:
-        - postgres
-        container_name: netbox
-        depends_on:
-        - postgres
-        environment:
-            SUPERUSER_NAME: admin
-            SUPERUSER_EMAIL: admin@example.com
-            SUPERUSER_PASSWORD: admin
-            ALLOWED_HOSTS: localhost
-            DB_NAME: netbox
-            DB_USER: netbox
-            DB_PASSWORD: J5brHrAXFLQSif0K
-            DB_HOST: postgres
-            SECRET_KEY: r8OwDznj!!dci#P9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj
-            EMAIL_SERVER: localhost
-            EMAIL_PORT: 25
-            EMAIL_USERNAME: foo
-            EMAIL_PASSWORD: bar
-            EMAIL_TIMEOUT: 10
-            EMAIL_FROM: netbox@bar.com
-            NETBOX_USERNAME: guest
-            NETBOX_PASSWORD: guest
-        volumes:
-        - netbox-static-files:/opt/netbox/netbox/static
-    nginx:
-        image: nginx:1.11.1-alpine
-        links:
-        - netbox
-        container_name: nginx
-        command: nginx -g 'daemon off;' -c /etc/netbox-nginx/nginx.conf
-        depends_on:
-        - netbox
-        ports:
-        - 80:80
-        volumes_from:
-        - netbox
-volumes:
-    netbox-static-files:
-        driver: local

+ 0 - 22
docker/docker-entrypoint.sh

@@ -1,22 +0,0 @@
-#!/bin/bash
-set -e
-
-# run db migrations (retry on error)
-while ! /opt/netbox/netbox/manage.py migrate 2>&1; do
-    sleep 5
-done
-
-# create superuser silently
-if [[ -z ${SUPERUSER_NAME} || -z ${SUPERUSER_EMAIL} || -z ${SUPERUSER_PASSWORD} ]]; then
-        SUPERUSER_NAME='admin'
-        SUPERUSER_EMAIL='admin@example.com'
-        SUPERUSER_PASSWORD='admin'
-        echo "Using defaults: Username: ${SUPERUSER_NAME}, E-Mail: ${SUPERUSER_EMAIL}, Password: ${SUPERUSER_PASSWORD}"
-fi
-echo "from django.contrib.auth.models import User; User.objects.create_superuser('${SUPERUSER_NAME}', '${SUPERUSER_EMAIL}', '${SUPERUSER_PASSWORD}')" | python /opt/netbox/netbox/manage.py shell
-
-# copy static files
-/opt/netbox/netbox/manage.py collectstatic --no-input
-
-# start unicorn
-gunicorn --log-level debug --debug --error-logfile /dev/stderr --log-file /dev/stdout -c /opt/netbox/gunicorn_config.py netbox.wsgi

+ 0 - 5
docker/gunicorn_config.py

@@ -1,5 +0,0 @@
-command = '/usr/bin/gunicorn'
-pythonpath = '/opt/netbox/netbox'
-bind = '0.0.0.0:8001'
-workers = 3
-user = 'root'

+ 0 - 35
docker/nginx.conf

@@ -1,35 +0,0 @@
-worker_processes 1;
-
-events {
-    worker_connections  1024;
-}
-
-http {
-    include       /etc/nginx/mime.types;
-    default_type  application/octet-stream;
-    sendfile        on;
-    tcp_nopush     on;
-    keepalive_timeout  65;
-    gzip  on;
-    server_tokens off;
-
-    server {
-        listen 80;
-
-        server_name localhost;
-
-        access_log off;
-
-        location /static/ {
-            alias /opt/netbox/netbox/static/;
-        }
-
-        location / {
-            proxy_pass http://netbox:8001;
-            proxy_set_header X-Forwarded-Host $server_name;
-            proxy_set_header X-Real-IP $remote_addr;
-            proxy_set_header X-Forwarded-Proto $scheme;
-            add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
-        }
-    }
-}

+ 0 - 19
docs/api-integration.md

@@ -1,19 +0,0 @@
-# API Integration
-
-NetBox features a read-only REST API which can be used to integrate it with
-other applications.
-
-In the future, both read and write actions will be available via the API.
-
-## Clients
-
-The easiest way to start integrating your applications with NetBox is to make
-use of an API client.  If you build or discover an API client that is not part
-of this list, please send a pull request!
-
-- **Go**: [github.com/digitalocean/go-netbox](https://github.com/digitalocean/go-netbox)
-
-## Documentation
-
-If you wish to build a new API client or simply explore the NetBox API,
-Swagger documentation can be found at the URL `/api/docs/` on a NetBox server.

+ 48 - 0
docs/api/authentication.md

@@ -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 - 0
docs/api/examples.md

@@ -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 - 0
docs/api/overview.md

@@ -0,0 +1,138 @@
+NetBox v2.0 and later includes a full-featured REST API that allows its data model to be read and manipulated externally.
+
+# URL Hierarchy
+
+NetBox's entire REST API is housed under the API root, `/api/`. The API's URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application:
+
+* /api/circuits/providers/
+* /api/circuits/circuits/
+
+Likewise, the site, rack, and device objects are located under the "DCIM" application:
+
+* /api/dcim/sites/
+* /api/dcim/racks/
+* /api/dcim/devices/
+
+The full hierarchy of available endpoints can be viewed by navigating to the API root (e.g. /api/) in a web browser.
+
+Each model generally has two URLs associated with it: a list URL and a detail URL. The list URL is used to request a list of multiple objects or to create a new object. The detail URL is used to retrieve, update, or delete an existing object. All objects are referenced by their numeric primary key (ID).
+
+* /api/dcim/devices/ - List devices or create a new device
+* /api/dcim/devices/123/ - Retrieve, update, or delete the device with ID 123
+
+Lists of objects can be filtered using a set of query parameters. For example, to find all interfaces belonging to the device with ID 123:
+
+```
+GET /api/dcim/interfaces/?device_id=123
+```
+
+# Serialization
+
+The NetBox API employs three types of serializers to represent model data:
+
+* Base serializer
+* Nested serializer
+* Writable serializer
+
+The base serializer is used to represent the default view of a model. This includes all database table fields which comprise the model, and may include additional metadata. A base serializer includes relationships to parent objects, but **does not** include child objects. For example, the `VLANSerializer` includes a nested representation its parent VLANGroup (if any), but does not include any assigned Prefixes.
+
+```
+{
+    "id": 1048,
+    "site": {
+        "id": 7,
+        "url": "http://localhost:8000/api/dcim/sites/7/",
+        "name": "Corporate HQ",
+        "slug": "corporate-hq"
+    },
+    "group": {
+        "id": 4,
+        "url": "http://localhost:8000/api/ipam/vlan-groups/4/",
+        "name": "Production",
+        "slug": "production"
+    },
+    "vid": 101,
+    "name": "Users-Floor1",
+    "tenant": null,
+    "status": [
+        1,
+        "Active"
+    ],
+    "role": {
+        "id": 9,
+        "url": "http://localhost:8000/api/ipam/roles/9/",
+        "name": "User Access",
+        "slug": "user-access"
+    },
+    "description": "",
+    "display_name": "101 (Users-Floor1)",
+    "custom_fields": {}
+}
+```
+
+Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name.
+
+When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
+
+```
+{
+    "id": 1201,
+    "site": 7,
+    "group": 4,
+    "vid": 102,
+    "name": "Users-Floor2",
+    "tenant": null,
+    "status": 1,
+    "role": 9,
+    "description": ""
+}
+```
+
+# Pagination
+
+API responses which contain a list of objects (for example, a request to `/api/dcim/devices/`) will be paginated to avoid unnecessary overhead. The root JSON object will contain the following attributes:
+
+* `count`: The total count of all objects matching the query
+* `next`: A hyperlink to the next page of results (if applicable)
+* `previous`: A hyperlink to the previous page of results (if applicable)
+* `results`: The list of returned objects
+
+Here is an example of a paginated response:
+
+```
+HTTP 200 OK
+Allow: GET, POST, OPTIONS
+Content-Type: application/json
+Vary: Accept
+
+{
+    "count": 2861,
+    "next": "http://localhost:8000/api/dcim/devices/?limit=50&offset=50",
+    "previous": null,
+    "results": [
+        {
+            "id": 123,
+            "name": "DeviceName123",
+            ...
+        },
+        ...
+    ]
+}
+```
+
+The default page size derives from the [`PAGINATE_COUNT`](../configuration/optional-settings/#paginate_count) configuration setting, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for:
+
+```
+http://localhost:8000/api/dcim/devices/?limit=100
+```
+
+The response will return devices 1 through 100. The URL provided in the `next` attribute of the response will return devices 101 through 200:
+
+```
+{
+    "count": 2861,
+    "next": "http://localhost:8000/api/dcim/devices/?limit=100&offset=100",
+    "previous": null,
+    "results": [...]
+}
+```

+ 136 - 0
docs/api/working-with-secrets.md

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

+ 16 - 0
docs/configuration/optional-settings.md

@@ -38,6 +38,22 @@ BASE_PATH = 'netbox/'
 
 ---
 
+## CORS_ORIGIN_ALLOW_ALL
+
+Default: False
+
+If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below).
+
+---
+
+## CORS_ORIGIN_WHITELIST
+
+## CORS_ORIGIN_REGEX_WHITELIST
+
+These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.)
+
+---
+
 ## DEBUG
 
 Default: False

+ 5 - 5
docs/data-model/dcim.md

@@ -89,9 +89,12 @@ A device's platform is used to denote the type of software running on it. This c
 
 The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
 
-### Modules
+### Inventory Items
 
-A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each module can optionally be assigned to a manufacturer.
+Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each item can optionally be assigned a manufacturer.
+
+!!! note
+    Prior to version 2.0, inventory items were called modules.
 
 ### Components
 
@@ -109,6 +112,3 @@ Console ports connect only to console server ports, and power ports connect only
 Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
 
 Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
-
-!!! note
-    Child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply, which do not provide their own management plane.

+ 7 - 0
docs/data-model/extras.md

@@ -123,3 +123,10 @@ access-switch\d+,oob-switch\d+
 ```
 
 Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.
+
+# Image Attachments
+
+Certain objects within NetBox (namely sites, racks, and devices) can have photos or other images attached to them. (Note that _only_ image files are supported.) Each attachment may optionally be assigned a name; if omitted, the attachment will be represented by its file name.
+
+!!! note
+    If you experience a server error while attempting to upload an image attachment, verify that the system user NetBox runs as has write permission to the media root directory (`netbox/media/`).

+ 0 - 51
docs/installation/docker.md

@@ -1,51 +0,0 @@
-This guide demonstrates how to build and run NetBox as a Docker container. It assumes that the latest versions of [Docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) are already installed in your host.
-
-# Quickstart
-
-To get NetBox up and running:
-
-```no-highlight
-# git clone -b master https://github.com/digitalocean/netbox.git
-# cd netbox
-# docker-compose up -d
-```
-
-The application will be available on http://localhost/ after a few minutes.
-
-Default credentials:
-
-* Username: **admin**
-* Password: **admin**
-
-# Configuration
-
-You can configure the app at runtime using variables (see `docker-compose.yml`). Possible environment variables include:
-
-* SUPERUSER_NAME
-* SUPERUSER_EMAIL
-* SUPERUSER_PASSWORD
-* ALLOWED_HOSTS
-* DB_NAME
-* DB_USER
-* DB_PASSWORD
-* DB_HOST
-* DB_PORT
-* SECRET_KEY
-* EMAIL_SERVER
-* EMAIL_PORT
-* EMAIL_USERNAME
-* EMAIL_PASSWORD
-* EMAIL_TIMEOUT
-* EMAIL_FROM
-* LOGIN_REQUIRED
-* MAINTENANCE_MODE
-* NETBOX_USERNAME
-* NETBOX_PASSWORD
-* PAGINATE_COUNT
-* TIME_ZONE
-* DATE_FORMAT
-* SHORT_DATE_FORMAT
-* TIME_FORMAT
-* SHORT_TIME_FORMAT
-* DATETIME_FORMAT
-* SHORT_DATETIME_FORMAT

+ 5 - 2
mkdocs.yml

@@ -8,7 +8,6 @@ pages:
         - 'Web Server': 'installation/web-server.md'
         - 'LDAP (Optional)': 'installation/ldap.md'
         - 'Upgrading': 'installation/upgrading.md'
-        - 'Alternate Install: Docker': 'installation/docker.md'
     - 'Configuration':
         - 'Mandatory Settings': 'configuration/mandatory-settings.md'
         - 'Optional Settings': 'configuration/optional-settings.md'
@@ -19,7 +18,11 @@ pages:
         - 'Secrets': 'data-model/secrets.md'
         - 'Tenancy': 'data-model/tenancy.md'
         - 'Extras': 'data-model/extras.md'
-    - 'API Integration': 'api-integration.md'
+    - 'API':
+        - 'Overview': 'api/overview.md'
+        - 'Authentication': 'api/authentication.md'
+        - 'Working with Secrets': 'api/working-with-secrets.md'
+        - 'Examples': 'api/examples.md'
 
 markdown_extensions:
     - admonition:

+ 0 - 29
netbox/circuits/admin.py

@@ -1,29 +0,0 @@
-from django.contrib import admin
-
-from .models import Provider, CircuitType, Circuit
-
-
-@admin.register(Provider)
-class ProviderAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug', 'asn']
-
-
-@admin.register(CircuitType)
-class CircuitTypeAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug']
-
-
-@admin.register(Circuit)
-class CircuitAdmin(admin.ModelAdmin):
-    list_display = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate_human']
-    list_filter = ['provider', 'type', 'tenant']
-
-    def get_queryset(self, request):
-        qs = super(CircuitAdmin, self).get_queryset(request)
-        return qs.select_related('provider', 'type', 'tenant')

+ 72 - 27
netbox/circuits/api/serializers.py

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

+ 18 - 17
netbox/circuits/api/urls.py

@@ -1,25 +1,26 @@
-from django.conf.urls import url
+from rest_framework import routers
 
-from extras.models import GRAPH_TYPE_PROVIDER
-from extras.api.views import GraphListView
+from . import views
 
-from .views import *
 
+class CircuitsRootView(routers.APIRootView):
+    """
+    Circuits API root view
+    """
+    def get_view_name(self):
+        return 'Circuits'
 
-urlpatterns = [
 
-    # 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'),
+router = routers.DefaultRouter()
+router.APIRootView = CircuitsRootView
 
-    # Circuit types
-    url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'),
-    url(r'^circuit-types/(?P<pk>\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'),
+# Providers
+router.register(r'providers', views.ProviderViewSet)
 
-    # Circuits
-    url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'),
-    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)
 
-]
+app_name = 'circuits-api'
+urlpatterns = router.urls

+ 47 - 40
netbox/circuits/api/views.py

@@ -1,58 +1,65 @@
-from rest_framework import generics
+from django.shortcuts import get_object_or_404
 
-from circuits.models import Provider, CircuitType, Circuit
-from circuits.filters import CircuitFilter
+from rest_framework.decorators import detail_route
+from rest_framework.response import Response
+from rest_framework.viewsets import ModelViewSet
 
-from extras.api.views import CustomFieldModelAPIView
+from circuits import filters
+from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
+from extras.models import Graph, GRAPH_TYPE_PROVIDER
+from extras.api.serializers import RenderedGraphSerializer
+from extras.api.views import CustomFieldModelViewSet
+from utilities.api import WritableSerializerMixin
 from . import serializers
 
 
-class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView):
-    """
-    List all providers
-    """
-    queryset = Provider.objects.prefetch_related('custom_field_values__field')
-    serializer_class = serializers.ProviderSerializer
-
+#
+# Providers
+#
 
-class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
-    """
-    Retrieve a single provider
-    """
-    queryset = Provider.objects.prefetch_related('custom_field_values__field')
+class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+    queryset = Provider.objects.all()
     serializer_class = serializers.ProviderSerializer
+    write_serializer_class = serializers.WritableProviderSerializer
+    filter_class = filters.ProviderFilter
 
+    @detail_route()
+    def graphs(self, request, pk=None):
+        """
+        A convenience method for rendering graphs for a particular provider.
+        """
+        provider = get_object_or_404(Provider, pk=pk)
+        queryset = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER)
+        serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
+        return Response(serializer.data)
 
-class CircuitTypeListView(generics.ListAPIView):
-    """
-    List all circuit types
-    """
-    queryset = CircuitType.objects.all()
-    serializer_class = serializers.CircuitTypeSerializer
 
+#
+#  Circuit Types
+#
 
-class CircuitTypeDetailView(generics.RetrieveAPIView):
-    """
-    Retrieve a single circuit type
-    """
+class CircuitTypeViewSet(ModelViewSet):
     queryset = CircuitType.objects.all()
     serializer_class = serializers.CircuitTypeSerializer
 
 
-class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
-    """
-    List circuits (filterable)
-    """
-    queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
-        .prefetch_related('custom_field_values__field')
+#
+# Circuits
+#
+
+class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
     serializer_class = serializers.CircuitSerializer
-    filter_class = CircuitFilter
+    write_serializer_class = serializers.WritableCircuitSerializer
+    filter_class = filters.CircuitFilter
 
 
-class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
-    """
-    Retrieve a single circuit
-    """
-    queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
-        .prefetch_related('custom_field_values__field')
-    serializer_class = serializers.CircuitSerializer
+#
+# Circuit Terminations
+#
+
+class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
+    serializer_class = serializers.CircuitTerminationSerializer
+    write_serializer_class = serializers.WritableCircuitTerminationSerializer
+    filter_class = filters.CircuitTerminationFilter

+ 13 - 2
netbox/circuits/filters.py

@@ -6,8 +6,7 @@ from dcim.models import Site
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
-
-from .models import Provider, Circuit, CircuitType
+from .models import Provider, Circuit, CircuitTermination, CircuitType
 
 
 class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -107,3 +106,15 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
             Q(description__icontains=value) |
             Q(comments__icontains=value)
         ).distinct()
+
+
+class CircuitTerminationFilter(django_filters.FilterSet):
+    circuit_id = django_filters.ModelMultipleChoiceFilter(
+        name='circuit',
+        queryset=Circuit.objects.all(),
+        label='Circuit',
+    )
+
+    class Meta:
+        model = CircuitTermination
+        fields = ['term_side', 'site']

+ 2 - 2
netbox/circuits/forms.py

@@ -183,7 +183,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
         )
     )
@@ -192,7 +192,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
         required=False,
         label='Interface',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
+            api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
             disabled_indicator='is_connected'
         )
     )

+ 1 - 1
netbox/circuits/models.py

@@ -1,6 +1,6 @@
 from django.contrib.contenttypes.fields import GenericRelation
-from django.core.urlresolvers import reverse
 from django.db import models
+from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 
 from dcim.fields import ASNField

+ 35 - 17
netbox/circuits/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, SearchTable, ToggleColumn
 
 from .models import Circuit, CircuitType, Provider
 
@@ -19,9 +19,7 @@ CIRCUITTYPE_ACTIONS = """
 
 class ProviderTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn('circuits:provider', args=[Accessor('slug')], verbose_name='Name')
-    asn = tables.Column(verbose_name='ASN')
-    account = tables.Column(verbose_name='Account')
+    name = tables.LinkColumn()
     circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits')
 
     class Meta(BaseTable.Meta):
@@ -29,17 +27,25 @@ class ProviderTable(BaseTable):
         fields = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
 
+class ProviderSearchTable(SearchTable):
+    name = tables.LinkColumn()
+
+    class Meta(SearchTable.Meta):
+        model = Provider
+        fields = ('name', 'asn', 'account')
+
+
 #
 # Circuit types
 #
 
 class CircuitTypeTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
+    name = tables.LinkColumn()
     circuit_count = tables.Column(verbose_name='Circuits')
-    slug = tables.Column(verbose_name='Slug')
-    actions = tables.TemplateColumn(template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}},
-                                    verbose_name='')
+    actions = tables.TemplateColumn(
+        template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
+    )
 
     class Meta(BaseTable.Meta):
         model = CircuitType
@@ -52,16 +58,28 @@ class CircuitTypeTable(BaseTable):
 
 class CircuitTable(BaseTable):
     pk = ToggleColumn()
-    cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID')
-    type = tables.Column(verbose_name='Type')
-    provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    a_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
-                               args=[Accessor('termination_a.site.slug')])
-    z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
-                               args=[Accessor('termination_z.site.slug')])
-    description = tables.Column(verbose_name='Description')
+    cid = tables.LinkColumn(verbose_name='ID')
+    provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    a_side = tables.LinkColumn(
+        'dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
+        args=[Accessor('termination_a.site.slug')]
+    )
+    z_side = tables.LinkColumn(
+        'dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
+        args=[Accessor('termination_z.site.slug')]
+    )
 
     class Meta(BaseTable.Meta):
         model = Circuit
         fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')
+
+
+class CircuitSearchTable(SearchTable):
+    cid = tables.LinkColumn(verbose_name='ID')
+    provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(SearchTable.Meta):
+        model = Circuit
+        fields = ('cid', 'type', 'provider', 'tenant', 'description')

+ 0 - 0
netbox/circuits/tests/__init__.py


+ 329 - 0
netbox/circuits/tests/test_api.py

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

+ 1 - 0
netbox/circuits/urls.py

@@ -3,6 +3,7 @@ from django.conf.urls import url
 from . import views
 
 
+app_name = 'circuits'
 urlpatterns = [
 
     # Providers

+ 1 - 1
netbox/circuits/views.py

@@ -1,10 +1,10 @@
 from django.contrib import messages
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
-from django.core.urlresolvers import reverse
 from django.db import transaction
 from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
 
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
 from utilities.forms import ConfirmationForm

+ 0 - 212
netbox/dcim/admin.py

@@ -1,212 +0,0 @@
-from django.contrib import admin
-from django.db.models import Count
-
-from mptt.admin import MPTTModelAdmin
-
-from .models import (
-    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
-    PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region,
-    Site,
-)
-
-
-@admin.register(Region)
-class RegionAdmin(MPTTModelAdmin):
-    list_display = ['name', 'parent', 'slug']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(Site)
-class SiteAdmin(admin.ModelAdmin):
-    list_display = ['name', 'slug', 'facility', 'asn']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(RackGroup)
-class RackGroupAdmin(admin.ModelAdmin):
-    list_display = ['name', 'slug', 'site']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(RackRole)
-class RackRoleAdmin(admin.ModelAdmin):
-    list_display = ['name', 'slug', 'color']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(Rack)
-class RackAdmin(admin.ModelAdmin):
-    list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height']
-
-
-@admin.register(RackReservation)
-class RackRackReservationAdmin(admin.ModelAdmin):
-    list_display = ['rack', 'units', 'description', 'user', 'created']
-
-
-#
-# Device types
-#
-
-@admin.register(Manufacturer)
-class ManufacturerAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug']
-
-
-class ConsolePortTemplateAdmin(admin.TabularInline):
-    model = ConsolePortTemplate
-
-
-class ConsoleServerPortTemplateAdmin(admin.TabularInline):
-    model = ConsoleServerPortTemplate
-
-
-class PowerPortTemplateAdmin(admin.TabularInline):
-    model = PowerPortTemplate
-
-
-class PowerOutletTemplateAdmin(admin.TabularInline):
-    model = PowerOutletTemplate
-
-
-class InterfaceTemplateAdmin(admin.TabularInline):
-    model = InterfaceTemplate
-
-
-class DeviceBayTemplateAdmin(admin.TabularInline):
-    model = DeviceBayTemplate
-
-
-@admin.register(DeviceType)
-class DeviceTypeAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['model'],
-    }
-    inlines = [
-        ConsolePortTemplateAdmin,
-        ConsoleServerPortTemplateAdmin,
-        PowerPortTemplateAdmin,
-        PowerOutletTemplateAdmin,
-        InterfaceTemplateAdmin,
-        DeviceBayTemplateAdmin,
-    ]
-    list_display = ['model', 'manufacturer', 'slug', 'part_number', 'u_height', 'console_ports', 'console_server_ports',
-                    'power_ports', 'power_outlets', 'interfaces', 'device_bays']
-    list_filter = ['manufacturer']
-
-    def get_queryset(self, request):
-        return DeviceType.objects.annotate(
-            console_port_count=Count('console_port_templates', distinct=True),
-            cs_port_count=Count('cs_port_templates', distinct=True),
-            power_port_count=Count('power_port_templates', distinct=True),
-            power_outlet_count=Count('power_outlet_templates', distinct=True),
-            interface_count=Count('interface_templates', distinct=True),
-            devicebay_count=Count('device_bay_templates', distinct=True),
-        )
-
-    def console_ports(self, instance):
-        return instance.console_port_count
-
-    def console_server_ports(self, instance):
-        return instance.cs_port_count
-
-    def power_ports(self, instance):
-        return instance.power_port_count
-
-    def power_outlets(self, instance):
-        return instance.power_outlet_count
-
-    def interfaces(self, instance):
-        return instance.interface_count
-
-    def device_bays(self, instance):
-        return instance.devicebay_count
-
-
-#
-# Devices
-#
-
-@admin.register(DeviceRole)
-class DeviceRoleAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug', 'color']
-
-
-@admin.register(Platform)
-class PlatformAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'rpc_client']
-
-
-class ConsolePortAdmin(admin.TabularInline):
-    model = ConsolePort
-    readonly_fields = ['cs_port']
-
-
-class ConsoleServerPortAdmin(admin.TabularInline):
-    model = ConsoleServerPort
-
-
-class PowerPortAdmin(admin.TabularInline):
-    model = PowerPort
-    readonly_fields = ['power_outlet']
-
-
-class PowerOutletAdmin(admin.TabularInline):
-    model = PowerOutlet
-
-
-class InterfaceAdmin(admin.TabularInline):
-    model = Interface
-
-
-class DeviceBayAdmin(admin.TabularInline):
-    model = DeviceBay
-    fk_name = 'device'
-    readonly_fields = ['installed_device']
-
-
-class ModuleAdmin(admin.TabularInline):
-    model = Module
-    readonly_fields = ['parent', 'discovered']
-
-
-@admin.register(Device)
-class DeviceAdmin(admin.ModelAdmin):
-    inlines = [
-        ConsolePortAdmin,
-        ConsoleServerPortAdmin,
-        PowerPortAdmin,
-        PowerOutletAdmin,
-        InterfaceAdmin,
-        DeviceBayAdmin,
-        ModuleAdmin,
-    ]
-    list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
-                    'serial']
-    list_filter = ['device_role']
-
-    def get_queryset(self, request):
-        qs = super(DeviceAdmin, self).get_queryset(request)
-        return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip4', 'primary_ip6', 'rack')
-
-    def device_type_full_name(self, obj):
-        return obj.device_type.full_name
-    device_type_full_name.short_description = 'Device type'

+ 364 - 176
netbox/dcim/api/serializers.py

@@ -1,28 +1,40 @@
 from rest_framework import serializers
+from rest_framework.validators import UniqueTogetherValidator
 
 from ipam.models import IPAddress
 from dcim.models import (
-    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType,
-    DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet,
-    PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_FRONT,
-    RACK_FACE_REAR, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
+    CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
+    DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
+    InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
+    RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
 )
-from extras.api.serializers import CustomFieldSerializer
-from tenancy.api.serializers import TenantNestedSerializer
+from extras.api.customfields import CustomFieldModelSerializer
+from tenancy.api.serializers import NestedTenantSerializer
+from utilities.api import ChoiceFieldSerializer
 
 
 #
 # Regions
 #
 
-class RegionNestedSerializer(serializers.ModelSerializer):
+class NestedRegionSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
 
     class Meta:
         model = Region
-        fields = ['id', 'name', 'slug']
+        fields = ['id', 'url', 'name', 'slug']
 
 
 class RegionSerializer(serializers.ModelSerializer):
+    parent = NestedRegionSerializer()
+
+    class Meta:
+        model = Region
+        fields = ['id', 'name', 'slug', 'parent']
+
+
+class WritableRegionSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Region
@@ -33,21 +45,35 @@ class RegionSerializer(serializers.ModelSerializer):
 # Sites
 #
 
-class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    region = RegionNestedSerializer()
-    tenant = TenantNestedSerializer()
+class SiteSerializer(CustomFieldModelSerializer):
+    region = NestedRegionSerializer()
+    tenant = NestedTenantSerializer()
 
     class Meta:
         model = Site
-        fields = ['id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
-                  'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
-                  'count_vlans', 'count_racks', 'count_devices', 'count_circuits']
+        fields = [
+            'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
+            'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
+            'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
+        ]
 
 
-class SiteNestedSerializer(SiteSerializer):
+class NestedSiteSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
+
+    class Meta:
+        model = Site
+        fields = ['id', 'url', 'name', 'slug']
 
-    class Meta(SiteSerializer.Meta):
-        fields = ['id', 'name', 'slug']
+
+class WritableSiteSerializer(CustomFieldModelSerializer):
+
+    class Meta:
+        model = Site
+        fields = [
+            'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
+            'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
+        ]
 
 
 #
@@ -55,17 +81,26 @@ class SiteNestedSerializer(SiteSerializer):
 #
 
 class RackGroupSerializer(serializers.ModelSerializer):
-    site = SiteNestedSerializer()
+    site = NestedSiteSerializer()
 
     class Meta:
         model = RackGroup
         fields = ['id', 'name', 'slug', 'site']
 
 
-class RackGroupNestedSerializer(RackGroupSerializer):
+class NestedRackGroupSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
 
-    class Meta(SiteSerializer.Meta):
-        fields = ['id', 'name', 'slug']
+    class Meta:
+        model = RackGroup
+        fields = ['id', 'url', 'name', 'slug']
+
+
+class WritableRackGroupSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = RackGroup
+        fields = ['id', 'name', 'slug', 'site']
 
 
 #
@@ -79,61 +114,87 @@ class RackRoleSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'color']
 
 
-class RackRoleNestedSerializer(RackRoleSerializer):
+class NestedRackRoleSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
 
-    class Meta(RackRoleSerializer.Meta):
-        fields = ['id', 'name', 'slug']
+    class Meta:
+        model = RackRole
+        fields = ['id', 'url', 'name', 'slug']
 
 
 #
 # Racks
 #
 
-class RackReservationNestedSerializer(serializers.ModelSerializer):
+class RackSerializer(CustomFieldModelSerializer):
+    site = NestedSiteSerializer()
+    group = NestedRackGroupSerializer()
+    tenant = NestedTenantSerializer()
+    role = NestedRackRoleSerializer()
+    type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES)
+    width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES)
 
     class Meta:
-        model = RackReservation
-        fields = ['id', 'units', 'created', 'user', 'description']
+        model = Rack
+        fields = [
+            'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height',
+            'desc_units', 'comments', 'custom_fields',
+        ]
 
 
-class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    site = SiteNestedSerializer()
-    group = RackGroupNestedSerializer()
-    tenant = TenantNestedSerializer()
-    role = RackRoleNestedSerializer()
+class NestedRackSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
 
     class Meta:
         model = Rack
-        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
-                  'u_height', 'desc_units', 'comments', 'custom_fields']
+        fields = ['id', 'url', 'name', 'display_name']
 
 
-class RackNestedSerializer(RackSerializer):
+class WritableRackSerializer(CustomFieldModelSerializer):
 
-    class Meta(RackSerializer.Meta):
-        fields = ['id', 'name', 'facility_id', 'display_name']
+    class Meta:
+        model = Rack
+        fields = [
+            'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
+            'comments', 'custom_fields',
+        ]
+        # 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 validate(self, data):
 
-class RackDetailSerializer(RackSerializer):
-    front_units = serializers.SerializerMethodField()
-    rear_units = serializers.SerializerMethodField()
-    reservations = RackReservationNestedSerializer(many=True)
+        # Validate uniqueness of (site, facility_id) since we omitted the automatically-created validator from Meta.
+        if data.get('facility_id', None):
+            validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'facility_id'))
+            validator.set_context(self)
+            validator(data)
 
-    class Meta(RackSerializer.Meta):
-        fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width',
-                  'u_height', 'desc_units', 'reservations', 'comments', 'custom_fields', 'front_units', 'rear_units']
+        return data
 
-    def get_front_units(self, obj):
-        units = obj.get_rack_units(face=RACK_FACE_FRONT)
-        for u in units:
-            u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None
-        return units
 
-    def get_rear_units(self, obj):
-        units = obj.get_rack_units(face=RACK_FACE_REAR)
-        for u in units:
-            u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None
-        return units
+#
+# Rack units
+#
+
+class NestedDeviceSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
+
+    class Meta:
+        model = Device
+        fields = ['id', 'url', 'name', 'display_name']
+
+
+class RackUnitSerializer(serializers.Serializer):
+    """
+    A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
+    """
+    id = serializers.IntegerField(read_only=True)
+    name = serializers.CharField(read_only=True)
+    face = serializers.IntegerField(read_only=True)
+    device = NestedDeviceSerializer(read_only=True)
 
 
 #
@@ -141,13 +202,20 @@ class RackDetailSerializer(RackSerializer):
 #
 
 class RackReservationSerializer(serializers.ModelSerializer):
-    rack = RackNestedSerializer()
+    rack = NestedRackSerializer()
 
     class Meta:
         model = RackReservation
         fields = ['id', 'rack', 'units', 'created', 'user', 'description']
 
 
+class WritableRackReservationSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = RackReservation
+        fields = ['id', 'rack', 'units', 'description']
+
+
 #
 # Manufacturers
 #
@@ -159,88 +227,165 @@ class ManufacturerSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug']
 
 
-class ManufacturerNestedSerializer(ManufacturerSerializer):
+class NestedManufacturerSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
 
-    class Meta(ManufacturerSerializer.Meta):
-        pass
+    class Meta:
+        model = Manufacturer
+        fields = ['id', 'url', 'name', 'slug']
 
 
 #
 # Device types
 #
 
-class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    manufacturer = ManufacturerNestedSerializer()
-    subdevice_role = serializers.SerializerMethodField()
+class DeviceTypeSerializer(CustomFieldModelSerializer):
+    manufacturer = NestedManufacturerSerializer()
+    interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES)
+    subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES)
     instance_count = serializers.IntegerField(source='instances.count', read_only=True)
 
     class Meta:
         model = DeviceType
-        fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-                  'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
-                  'comments', 'custom_fields', 'instance_count']
+        fields = [
+            'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
+            'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
+            'instance_count',
+        ]
 
-    def get_subdevice_role(self, obj):
-        return {
-            SUBDEVICE_ROLE_PARENT: 'parent',
-            SUBDEVICE_ROLE_CHILD: 'child',
-            None: None,
-        }[obj.subdevice_role]
 
+class NestedDeviceTypeSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
+    manufacturer = NestedManufacturerSerializer()
 
-class DeviceTypeNestedSerializer(DeviceTypeSerializer):
+    class Meta:
+        model = DeviceType
+        fields = ['id', 'url', 'manufacturer', 'model', 'slug']
 
-    class Meta(DeviceTypeSerializer.Meta):
-        fields = ['id', 'manufacturer', 'model', 'slug']
 
+class WritableDeviceTypeSerializer(CustomFieldModelSerializer):
+
+    class Meta:
+        model = DeviceType
+        fields = [
+            'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
+            'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
+        ]
 
-class ConsolePortTemplateNestedSerializer(serializers.ModelSerializer):
+
+#
+# Console port templates
+#
+
+class ConsolePortTemplateSerializer(serializers.ModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
 
     class Meta:
         model = ConsolePortTemplate
-        fields = ['id', 'name']
+        fields = ['id', 'device_type', 'name']
 
 
-class ConsoleServerPortTemplateNestedSerializer(serializers.ModelSerializer):
+class WritableConsolePortTemplateSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = ConsolePortTemplate
+        fields = ['id', 'device_type', 'name']
+
+
+#
+# Console server port templates
+#
+
+class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
+
+    class Meta:
+        model = ConsoleServerPortTemplate
+        fields = ['id', 'device_type', 'name']
+
+
+class WritableConsoleServerPortTemplateSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = ConsoleServerPortTemplate
-        fields = ['id', 'name']
+        fields = ['id', 'device_type', 'name']
+
+
+#
+# Power port templates
+#
 
+class PowerPortTemplateSerializer(serializers.ModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
+
+    class Meta:
+        model = PowerPortTemplate
+        fields = ['id', 'device_type', 'name']
 
-class PowerPortTemplateNestedSerializer(serializers.ModelSerializer):
+
+class WritablePowerPortTemplateSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = PowerPortTemplate
-        fields = ['id', 'name']
+        fields = ['id', 'device_type', 'name']
+
+
+#
+# Power outlet templates
+#
+
+class PowerOutletTemplateSerializer(serializers.ModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
+
+    class Meta:
+        model = PowerOutletTemplate
+        fields = ['id', 'device_type', 'name']
 
 
-class PowerOutletTemplateNestedSerializer(serializers.ModelSerializer):
+class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = PowerOutletTemplate
-        fields = ['id', 'name']
+        fields = ['id', 'device_type', 'name']
+
 
+#
+# Interface templates
+#
 
-class InterfaceTemplateNestedSerializer(serializers.ModelSerializer):
+class InterfaceTemplateSerializer(serializers.ModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
+    form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
 
     class Meta:
         model = InterfaceTemplate
-        fields = ['id', 'name', 'form_factor', 'mgmt_only']
+        fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
+
+
+class WritableInterfaceTemplateSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = InterfaceTemplate
+        fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
+
+
+#
+# Device bay templates
+#
+
+class DeviceBayTemplateSerializer(serializers.ModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
+
+    class Meta:
+        model = DeviceBayTemplate
+        fields = ['id', 'device_type', 'name']
 
 
-class DeviceTypeDetailSerializer(DeviceTypeSerializer):
-    console_port_templates = ConsolePortTemplateNestedSerializer(many=True, read_only=True)
-    cs_port_templates = ConsoleServerPortTemplateNestedSerializer(many=True, read_only=True)
-    power_port_templates = PowerPortTemplateNestedSerializer(many=True, read_only=True)
-    power_outlet_templates = PowerPortTemplateNestedSerializer(many=True, read_only=True)
-    interface_templates = InterfaceTemplateNestedSerializer(many=True, read_only=True)
+class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer):
 
-    class Meta(DeviceTypeSerializer.Meta):
-        fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
-                  'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
-                  'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates',
-                  'power_outlet_templates', 'interface_templates']
+    class Meta:
+        model = DeviceBayTemplate
+        fields = ['id', 'device_type', 'name']
 
 
 #
@@ -254,10 +399,12 @@ class DeviceRoleSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'color']
 
 
-class DeviceRoleNestedSerializer(DeviceRoleSerializer):
+class NestedDeviceRoleSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
 
-    class Meta(DeviceRoleSerializer.Meta):
-        fields = ['id', 'name', 'slug']
+    class Meta:
+        model = DeviceRole
+        fields = ['id', 'url', 'name', 'slug']
 
 
 #
@@ -271,34 +418,39 @@ class PlatformSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'rpc_client']
 
 
-class PlatformNestedSerializer(PlatformSerializer):
+class NestedPlatformSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
 
-    class Meta(PlatformSerializer.Meta):
-        fields = ['id', 'name', 'slug']
+    class Meta:
+        model = Platform
+        fields = ['id', 'url', 'name', 'slug']
 
 
 #
 # Devices
 #
 
-# Cannot import ipam.api.IPAddressNestedSerializer due to circular dependency
-class DeviceIPAddressNestedSerializer(serializers.ModelSerializer):
+# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
+class DeviceIPAddressSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
 
     class Meta:
         model = IPAddress
-        fields = ['id', 'family', 'address']
-
-
-class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    device_type = DeviceTypeNestedSerializer()
-    device_role = DeviceRoleNestedSerializer()
-    tenant = TenantNestedSerializer()
-    platform = PlatformNestedSerializer()
-    site = SiteNestedSerializer()
-    rack = RackNestedSerializer()
-    primary_ip = DeviceIPAddressNestedSerializer()
-    primary_ip4 = DeviceIPAddressNestedSerializer()
-    primary_ip6 = DeviceIPAddressNestedSerializer()
+        fields = ['id', 'url', 'family', 'address']
+
+
+class DeviceSerializer(CustomFieldModelSerializer):
+    device_type = NestedDeviceTypeSerializer()
+    device_role = NestedDeviceRoleSerializer()
+    tenant = NestedTenantSerializer()
+    platform = NestedPlatformSerializer()
+    site = NestedSiteSerializer()
+    rack = NestedRackSerializer()
+    face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
+    status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
+    primary_ip = DeviceIPAddressSerializer()
+    primary_ip4 = DeviceIPAddressSerializer()
+    primary_ip6 = DeviceIPAddressSerializer()
     parent_device = serializers.SerializerMethodField()
 
     class Meta:
@@ -324,11 +476,25 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer):
         }
 
 
-class DeviceNestedSerializer(serializers.ModelSerializer):
+class WritableDeviceSerializer(CustomFieldModelSerializer):
 
     class Meta:
         model = Device
-        fields = ['id', 'name', 'display_name']
+        fields = [
+            'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
+            'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
+        ]
+        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=Device.objects.all(), fields=('rack', 'position', 'face'))
+            validator.set_context(self)
+            validator(data)
+
+        return data
 
 
 #
@@ -336,16 +502,18 @@ class DeviceNestedSerializer(serializers.ModelSerializer):
 #
 
 class ConsoleServerPortSerializer(serializers.ModelSerializer):
-    device = DeviceNestedSerializer()
+    device = NestedDeviceSerializer()
 
     class Meta:
         model = ConsoleServerPort
         fields = ['id', 'device', 'name', 'connected_console']
+        read_only_fields = ['connected_console']
 
 
-class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer):
+class WritableConsoleServerPortSerializer(serializers.ModelSerializer):
 
-    class Meta(ConsoleServerPortSerializer.Meta):
+    class Meta:
+        model = ConsoleServerPort
         fields = ['id', 'device', 'name']
 
 
@@ -354,18 +522,19 @@ class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer):
 #
 
 class ConsolePortSerializer(serializers.ModelSerializer):
-    device = DeviceNestedSerializer()
-    cs_port = ConsoleServerPortNestedSerializer()
+    device = NestedDeviceSerializer()
+    cs_port = ConsoleServerPortSerializer()
 
     class Meta:
         model = ConsolePort
         fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
 
 
-class ConsolePortNestedSerializer(ConsolePortSerializer):
+class WritableConsolePortSerializer(serializers.ModelSerializer):
 
-    class Meta(ConsolePortSerializer.Meta):
-        fields = ['id', 'device', 'name']
+    class Meta:
+        model = ConsolePort
+        fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
 
 
 #
@@ -373,16 +542,18 @@ class ConsolePortNestedSerializer(ConsolePortSerializer):
 #
 
 class PowerOutletSerializer(serializers.ModelSerializer):
-    device = DeviceNestedSerializer()
+    device = NestedDeviceSerializer()
 
     class Meta:
         model = PowerOutlet
         fields = ['id', 'device', 'name', 'connected_port']
+        read_only_fields = ['connected_port']
 
 
-class PowerOutletNestedSerializer(PowerOutletSerializer):
+class WritablePowerOutletSerializer(serializers.ModelSerializer):
 
-    class Meta(PowerOutletSerializer.Meta):
+    class Meta:
+        model = PowerOutlet
         fields = ['id', 'device', 'name']
 
 
@@ -391,59 +562,63 @@ class PowerOutletNestedSerializer(PowerOutletSerializer):
 #
 
 class PowerPortSerializer(serializers.ModelSerializer):
-    device = DeviceNestedSerializer()
-    power_outlet = PowerOutletNestedSerializer()
+    device = NestedDeviceSerializer()
+    power_outlet = PowerOutletSerializer()
 
     class Meta:
         model = PowerPort
         fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
 
 
-class PowerPortNestedSerializer(PowerPortSerializer):
+class WritablePowerPortSerializer(serializers.ModelSerializer):
 
-    class Meta(PowerPortSerializer.Meta):
-        fields = ['id', 'device', 'name']
+    class Meta:
+        model = PowerPort
+        fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
 
 
 #
 # Interfaces
 #
 
-class LAGInterfaceNestedSerializer(serializers.ModelSerializer):
-    form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
-
-    class Meta:
-        model = Interface
-        fields = ['id', 'name', 'form_factor']
-
-
 class InterfaceSerializer(serializers.ModelSerializer):
-    device = DeviceNestedSerializer()
-    form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
-    lag = LAGInterfaceNestedSerializer()
+    device = NestedDeviceSerializer()
+    form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
+    connection = serializers.SerializerMethodField(read_only=True)
+    connected_interface = serializers.SerializerMethodField(read_only=True)
 
     class Meta:
         model = Interface
         fields = [
-            'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected',
+            'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'connection',
+            'connected_interface',
         ]
 
+    def get_connection(self, obj):
+        if obj.connection:
+            return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data
+        return None
 
-class InterfaceNestedSerializer(InterfaceSerializer):
-    form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
+    def get_connected_interface(self, obj):
+        if obj.connected_interface:
+            return PeerInterfaceSerializer(obj.connected_interface, context=self.context).data
+        return None
 
-    class Meta(InterfaceSerializer.Meta):
-        fields = ['id', 'device', 'name']
 
+class PeerInterfaceSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
+    device = NestedDeviceSerializer()
 
-class InterfaceDetailSerializer(InterfaceSerializer):
-    connected_interface = InterfaceSerializer()
+    class Meta:
+        model = Interface
+        fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
 
-    class Meta(InterfaceSerializer.Meta):
-        fields = [
-            'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected',
-            'connected_interface',
-        ]
+
+class WritableInterfaceSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = Interface
+        fields = ['id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
 
 
 #
@@ -451,44 +626,39 @@ class InterfaceDetailSerializer(InterfaceSerializer):
 #
 
 class DeviceBaySerializer(serializers.ModelSerializer):
-    device = DeviceNestedSerializer()
+    device = NestedDeviceSerializer()
+    installed_device = NestedDeviceSerializer()
 
     class Meta:
         model = DeviceBay
-        fields = ['id', 'device', 'name']
-
-
-class DeviceBayNestedSerializer(DeviceBaySerializer):
-    installed_device = DeviceNestedSerializer()
-
-    class Meta(DeviceBaySerializer.Meta):
-        fields = ['id', 'name', 'installed_device']
+        fields = ['id', 'device', 'name', 'installed_device']
 
 
-class DeviceBayDetailSerializer(DeviceBaySerializer):
-    installed_device = DeviceNestedSerializer()
+class WritableDeviceBaySerializer(serializers.ModelSerializer):
 
-    class Meta(DeviceBaySerializer.Meta):
+    class Meta:
+        model = DeviceBay
         fields = ['id', 'device', 'name', 'installed_device']
 
 
 #
-# Modules
+# Inventory items
 #
 
-class ModuleSerializer(serializers.ModelSerializer):
-    device = DeviceNestedSerializer()
-    manufacturer = ManufacturerNestedSerializer()
+class InventoryItemSerializer(serializers.ModelSerializer):
+    device = NestedDeviceSerializer()
+    manufacturer = NestedManufacturerSerializer()
 
     class Meta:
-        model = Module
+        model = InventoryItem
         fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
 
 
-class ModuleNestedSerializer(ModuleSerializer):
+class WritableInventoryItemSerializer(serializers.ModelSerializer):
 
-    class Meta(ModuleSerializer.Meta):
-        fields = ['id', 'device', 'parent', 'name']
+    class Meta:
+        model = InventoryItem
+        fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered']
 
 
 #
@@ -496,6 +666,24 @@ class ModuleNestedSerializer(ModuleSerializer):
 #
 
 class InterfaceConnectionSerializer(serializers.ModelSerializer):
+    interface_a = PeerInterfaceSerializer()
+    interface_b = PeerInterfaceSerializer()
+    connection_status = ChoiceFieldSerializer(choices=CONNECTION_STATUS_CHOICES)
+
+    class Meta:
+        model = InterfaceConnection
+        fields = ['id', 'interface_a', 'interface_b', 'connection_status']
+
+
+class NestedInterfaceConnectionSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail')
+
+    class Meta:
+        model = InterfaceConnection
+        fields = ['id', 'url', 'connection_status']
+
+
+class WritableInterfaceConnectionSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = InterfaceConnection

+ 62 - 84
netbox/dcim/api/urls.py

@@ -1,84 +1,62 @@
-from django.conf.urls import url
-
-from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
-from extras.api.views import GraphListView, TopologyMapView
-
-from .views import *
-
-
-urlpatterns = [
-
-    # Regions
-    url(r'^regions/$', RegionListView.as_view(), name='region_list'),
-    url(r'^regions/(?P<pk>\d+)/$', RegionDetailView.as_view(), name='region_detail'),
-
-    # Sites
-    url(r'^sites/$', SiteListView.as_view(), name='site_list'),
-    url(r'^sites/(?P<pk>\d+)/$', SiteDetailView.as_view(), name='site_detail'),
-    url(r'^sites/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'),
-    url(r'^sites/(?P<site>\d+)/racks/$', RackListView.as_view(), name='site_racks'),
-
-    # Rack groups
-    url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'),
-    url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
-
-    # Rack roles
-    url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'),
-    url(r'^rack-roles/(?P<pk>\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'),
-
-    # Racks
-    url(r'^racks/$', RackListView.as_view(), name='rack_list'),
-    url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
-    url(r'^racks/(?P<pk>\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'),
-
-    # Rack reservations
-    url(r'^rack-reservations/$', RackReservationListView.as_view(), name='rackreservation_list'),
-    url(r'^rack-reservations/(?P<pk>\d+)/$', RackReservationDetailView.as_view(), name='rackreservation_detail'),
-
-    # Manufacturers
-    url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'),
-    url(r'^manufacturers/(?P<pk>\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'),
-
-    # Device types
-    url(r'^device-types/$', DeviceTypeListView.as_view(), name='devicetype_list'),
-    url(r'^device-types/(?P<pk>\d+)/$', DeviceTypeDetailView.as_view(), name='devicetype_detail'),
-
-    # Device roles
-    url(r'^device-roles/$', DeviceRoleListView.as_view(), name='devicerole_list'),
-    url(r'^device-roles/(?P<pk>\d+)/$', DeviceRoleDetailView.as_view(), name='devicerole_detail'),
-
-    # Platforms
-    url(r'^platforms/$', PlatformListView.as_view(), name='platform_list'),
-    url(r'^platforms/(?P<pk>\d+)/$', PlatformDetailView.as_view(), name='platform_detail'),
-
-    # Devices
-    url(r'^devices/$', DeviceListView.as_view(), name='device_list'),
-    url(r'^devices/(?P<pk>\d+)/$', DeviceDetailView.as_view(), name='device_detail'),
-    url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'),
-    url(r'^devices/(?P<pk>\d+)/console-ports/$', ConsolePortListView.as_view(), name='device_consoleports'),
-    url(r'^devices/(?P<pk>\d+)/console-server-ports/$', ConsoleServerPortListView.as_view(),
-        name='device_consoleserverports'),
-    url(r'^devices/(?P<pk>\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'),
-    url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
-    url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
-    url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
-    url(r'^devices/(?P<pk>\d+)/modules/$', ModuleListView.as_view(), name='device_modules'),
-
-    # Console ports
-    url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),
-
-    # Power ports
-    url(r'^power-ports/(?P<pk>\d+)/$', PowerPortView.as_view(), name='powerport'),
-
-    # Interfaces
-    url(r'^interfaces/(?P<pk>\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'),
-    url(r'^interfaces/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE},
-        name='interface_graphs'),
-    url(r'^interface-connections/$', InterfaceConnectionListView.as_view(), name='interfaceconnection_list'),
-    url(r'^interface-connections/(?P<pk>\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'),
-
-    # Miscellaneous
-    url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'),
-    url(r'^topology-maps/(?P<slug>[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'),
-
-]
+from rest_framework import routers
+
+from . import views
+
+
+class DCIMRootView(routers.APIRootView):
+    """
+    DCIM API root view
+    """
+    def get_view_name(self):
+        return 'DCIM'
+
+
+router = routers.DefaultRouter()
+router.APIRootView = DCIMRootView
+
+# Sites
+router.register(r'regions', views.RegionViewSet)
+router.register(r'sites', views.SiteViewSet)
+
+# Racks
+router.register(r'rack-groups', views.RackGroupViewSet)
+router.register(r'rack-roles', views.RackRoleViewSet)
+router.register(r'racks', views.RackViewSet)
+router.register(r'rack-reservations', views.RackReservationViewSet)
+
+# Device types
+router.register(r'manufacturers', views.ManufacturerViewSet)
+router.register(r'device-types', views.DeviceTypeViewSet)
+
+# Device type components
+router.register(r'console-port-templates', views.ConsolePortTemplateViewSet)
+router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
+router.register(r'power-port-templates', views.PowerPortTemplateViewSet)
+router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet)
+router.register(r'interface-templates', views.InterfaceTemplateViewSet)
+router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet)
+
+# Devices
+router.register(r'device-roles', views.DeviceRoleViewSet)
+router.register(r'platforms', views.PlatformViewSet)
+router.register(r'devices', views.DeviceViewSet)
+
+# Device components
+router.register(r'console-ports', views.ConsolePortViewSet)
+router.register(r'console-server-ports', views.ConsoleServerPortViewSet)
+router.register(r'power-ports', views.PowerPortViewSet)
+router.register(r'power-outlets', views.PowerOutletViewSet)
+router.register(r'interfaces', views.InterfaceViewSet)
+router.register(r'device-bays', views.DeviceBayViewSet)
+router.register(r'inventory-items', views.InventoryItemViewSet)
+
+# Connections
+router.register(r'console-connections', views.ConsoleConnectionViewSet, base_name='consoleconnections')
+router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
+router.register(r'interface-connections', views.InterfaceConnectionViewSet)
+
+# Miscellaneous
+router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
+
+app_name = 'dcim-api'
+urlpatterns = router.urls

+ 197 - 380
netbox/dcim/api/views.py

@@ -1,23 +1,23 @@
-from rest_framework import generics
-from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
+from rest_framework.decorators import detail_route
+from rest_framework.mixins import ListModelMixin
+from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
-from rest_framework.settings import api_settings
-from rest_framework.views import APIView
+from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
 
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.http import Http404
 from django.shortcuts import get_object_or_404
 
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection,
-    Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
-    VIRTUAL_IFACE_TYPES,
+    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
+    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
+    InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
+    RackReservation, RackRole, Region, Site,
 )
 from dcim import filters
-from extras.api.views import CustomFieldModelAPIView
-from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer
-from utilities.api import ServiceUnavailable
+from extras.api.serializers import RenderedGraphSerializer
+from extras.api.views import CustomFieldModelViewSet
+from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
+from utilities.api import ServiceUnavailable, WritableSerializerMixin
 from .exceptions import MissingFilterException
 from . import serializers
 
@@ -26,79 +26,49 @@ from . import serializers
 # Regions
 #
 
-class RegionListView(generics.ListAPIView):
-    """
-    List all regions
-    """
-    queryset = Region.objects.all()
-    serializer_class = serializers.RegionSerializer
-
-
-class RegionDetailView(generics.RetrieveAPIView):
-    """
-    Retrieve a single region
-    """
+class RegionViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = Region.objects.all()
     serializer_class = serializers.RegionSerializer
+    write_serializer_class = serializers.WritableRegionSerializer
 
 
 #
 # Sites
 #
 
-class SiteListView(CustomFieldModelAPIView, generics.ListAPIView):
-    """
-    List all sites
-    """
-    queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
+class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+    queryset = Site.objects.select_related('region', 'tenant')
     serializer_class = serializers.SiteSerializer
+    write_serializer_class = serializers.WritableSiteSerializer
+    filter_class = filters.SiteFilter
 
-
-class SiteDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
-    """
-    Retrieve a single site
-    """
-    queryset = Site.objects.select_related('tenant').prefetch_related('custom_field_values__field')
-    serializer_class = serializers.SiteSerializer
+    @detail_route()
+    def graphs(self, request, pk=None):
+        """
+        A convenience method for rendering graphs for a particular site.
+        """
+        site = get_object_or_404(Site, pk=pk)
+        queryset = Graph.objects.filter(type=GRAPH_TYPE_SITE)
+        serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
+        return Response(serializer.data)
 
 
 #
 # Rack groups
 #
 
-class RackGroupListView(generics.ListAPIView):
-    """
-    List all rack groups
-    """
+class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
     queryset = RackGroup.objects.select_related('site')
     serializer_class = serializers.RackGroupSerializer
+    write_serializer_class = serializers.WritableRackGroupSerializer
     filter_class = filters.RackGroupFilter
 
 
-class RackGroupDetailView(generics.RetrieveAPIView):
-    """
-    Retrieve a single rack group
-    """
-    queryset = RackGroup.objects.select_related('site')
-    serializer_class = serializers.RackGroupSerializer
-
-
 #
 # Rack roles
 #
 
-class RackRoleListView(generics.ListAPIView):
-    """
-    List all rack roles
-    """
-    queryset = RackRole.objects.all()
-    serializer_class = serializers.RackRoleSerializer
-
-
-class RackRoleDetailView(generics.RetrieveAPIView):
-    """
-    Retrieve a single rack role
-    """
+class RackRoleViewSet(ModelViewSet):
     queryset = RackRole.objects.all()
     serializer_class = serializers.RackRoleSerializer
 
@@ -107,36 +77,17 @@ class RackRoleDetailView(generics.RetrieveAPIView):
 # Racks
 #
 
-class RackListView(CustomFieldModelAPIView, generics.ListAPIView):
-    """
-    List racks (filterable)
-    """
-    queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
-        .prefetch_related('custom_field_values__field')
+class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+    queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
     serializer_class = serializers.RackSerializer
+    write_serializer_class = serializers.WritableRackSerializer
     filter_class = filters.RackFilter
 
-
-class RackDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
-    """
-    Retrieve a single rack
-    """
-    queryset = Rack.objects.select_related('site', 'group__site', 'tenant')\
-        .prefetch_related('custom_field_values__field')
-    serializer_class = serializers.RackDetailSerializer
-
-
-#
-# Rack units
-#
-
-class RackUnitListView(APIView):
-    """
-    List rack units (by rack)
-    """
-
-    def get(self, request, pk):
-
+    @detail_route()
+    def units(self, request, pk=None):
+        """
+        List rack units (by rack)
+        """
         rack = get_object_or_404(Rack, pk=pk)
         face = request.GET.get('face', 0)
         exclude_pk = request.GET.get('exclude', None)
@@ -147,92 +98,98 @@ class RackUnitListView(APIView):
                 exclude_pk = None
         elevation = rack.get_rack_units(face, exclude_pk)
 
-        # Serialize Devices within the rack elevation
-        for u in elevation:
-            if u['device']:
-                u['device'] = serializers.DeviceNestedSerializer(instance=u['device']).data
-
-        return Response(elevation)
+        page = self.paginate_queryset(elevation)
+        if page is not None:
+            rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
+            return self.get_paginated_response(rack_units.data)
 
 
 #
 # Rack reservations
 #
 
-class RackReservationListView(generics.ListAPIView):
-    """
-    List all rack reservation
-    """
-    queryset = RackReservation.objects.all()
+class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = RackReservation.objects.select_related('rack')
     serializer_class = serializers.RackReservationSerializer
+    write_serializer_class = serializers.WritableRackReservationSerializer
     filter_class = filters.RackReservationFilter
 
-
-class RackReservationDetailView(generics.RetrieveAPIView):
-    """
-    Retrieve a single rack reservation
-    """
-    queryset = RackReservation.objects.all()
-    serializer_class = serializers.RackReservationSerializer
+    # Assign user from request
+    def perform_create(self, serializer):
+        serializer.save(user=self.request.user)
 
 
 #
 # Manufacturers
 #
 
-class ManufacturerListView(generics.ListAPIView):
-    """
-    List all hardware manufacturers
-    """
-    queryset = Manufacturer.objects.all()
-    serializer_class = serializers.ManufacturerSerializer
-
-
-class ManufacturerDetailView(generics.RetrieveAPIView):
-    """
-    Retrieve a single hardware manufacturers
-    """
+class ManufacturerViewSet(ModelViewSet):
     queryset = Manufacturer.objects.all()
     serializer_class = serializers.ManufacturerSerializer
 
 
 #
-# Device Types
+# Device types
 #
 
-class DeviceTypeListView(CustomFieldModelAPIView, generics.ListAPIView):
-    """
-    List device types (filterable)
-    """
-    queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
+class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+    queryset = DeviceType.objects.select_related('manufacturer')
     serializer_class = serializers.DeviceTypeSerializer
+    write_serializer_class = serializers.WritableDeviceTypeSerializer
     filter_class = filters.DeviceTypeFilter
 
 
-class DeviceTypeDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
-    """
-    Retrieve a single device type
-    """
-    queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field')
-    serializer_class = serializers.DeviceTypeDetailSerializer
+#
+# Device type components
+#
+
+class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
+    serializer_class = serializers.ConsolePortTemplateSerializer
+    write_serializer_class = serializers.WritableConsolePortTemplateSerializer
+    filter_class = filters.ConsolePortTemplateFilter
+
+
+class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
+    serializer_class = serializers.ConsoleServerPortTemplateSerializer
+    write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
+    filter_class = filters.ConsoleServerPortTemplateFilter
+
+
+class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
+    serializer_class = serializers.PowerPortTemplateSerializer
+    write_serializer_class = serializers.WritablePowerPortTemplateSerializer
+    filter_class = filters.PowerPortTemplateFilter
+
+
+class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
+    serializer_class = serializers.PowerOutletTemplateSerializer
+    write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
+    filter_class = filters.PowerOutletTemplateFilter
+
+
+class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
+    serializer_class = serializers.InterfaceTemplateSerializer
+    write_serializer_class = serializers.WritableInterfaceTemplateSerializer
+    filter_class = filters.InterfaceTemplateFilter
+
+
+class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
+    serializer_class = serializers.DeviceBayTemplateSerializer
+    write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
+    filter_class = filters.DeviceBayTemplateFilter
 
 
 #
 # Device roles
 #
 
-class DeviceRoleListView(generics.ListAPIView):
-    """
-    List all device roles
-    """
-    queryset = DeviceRole.objects.all()
-    serializer_class = serializers.DeviceRoleSerializer
-
-
-class DeviceRoleDetailView(generics.RetrieveAPIView):
-    """
-    Retrieve a single device role
-    """
+class DeviceRoleViewSet(ModelViewSet):
     queryset = DeviceRole.objects.all()
     serializer_class = serializers.DeviceRoleSerializer
 
@@ -241,18 +198,7 @@ class DeviceRoleDetailView(generics.RetrieveAPIView):
 # Platforms
 #
 
-class PlatformListView(generics.ListAPIView):
-    """
-    List all platforms
-    """
-    queryset = Platform.objects.all()
-    serializer_class = serializers.PlatformSerializer
-
-
-class PlatformDetailView(generics.RetrieveAPIView):
-    """
-    Retrieve a single platform
-    """
+class PlatformViewSet(ModelViewSet):
     queryset = Platform.objects.all()
     serializer_class = serializers.PlatformSerializer
 
@@ -261,284 +207,155 @@ class PlatformDetailView(generics.RetrieveAPIView):
 # Devices
 #
 
-class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView):
-    """
-    List devices (filterable)
-    """
+class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
     queryset = Device.objects.select_related(
-        'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay'
+        'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
     ).prefetch_related(
-        'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'custom_field_values__field'
+        'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
     )
     serializer_class = serializers.DeviceSerializer
+    write_serializer_class = serializers.WritableDeviceSerializer
     filter_class = filters.DeviceFilter
-    renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer]
-
 
-class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
-    """
-    Retrieve a single device
-    """
-    queryset = Device.objects.select_related(
-        'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay'
-    ).prefetch_related('custom_field_values__field')
-    serializer_class = serializers.DeviceSerializer
-
-
-#
-# Console ports
-#
-
-class ConsolePortListView(generics.ListAPIView):
-    """
-    List console ports (by device)
-    """
-    serializer_class = serializers.ConsolePortSerializer
-
-    def get_queryset(self):
-
-        device = get_object_or_404(Device, pk=self.kwargs['pk'])
-        return ConsolePort.objects.filter(device=device).select_related('cs_port')
-
-
-class ConsolePortView(generics.RetrieveUpdateDestroyAPIView):
-    permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
-    serializer_class = serializers.ConsolePortSerializer
-    queryset = ConsolePort.objects.all()
-
-
-#
-# Console server ports
-#
+    @detail_route(url_path='lldp-neighbors')
+    def lldp_neighbors(self, request, pk):
+        """
+        Retrieve live LLDP neighbors of a device
+        """
+        device = get_object_or_404(Device, pk=pk)
+        if not device.primary_ip:
+            raise ServiceUnavailable("No IP configured for this device.")
 
-class ConsoleServerPortListView(generics.ListAPIView):
-    """
-    List console server ports (by device)
-    """
-    serializer_class = serializers.ConsoleServerPortSerializer
+        RPC = device.get_rpc_client()
+        if not RPC:
+            raise ServiceUnavailable("No RPC client available for this platform ({}).".format(device.platform))
 
-    def get_queryset(self):
+        # Connect to device and retrieve inventory info
+        try:
+            with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client:
+                lldp_neighbors = rpc_client.get_lldp_neighbors()
+        except:
+            raise ServiceUnavailable("Error connecting to the remote device.")
 
-        device = get_object_or_404(Device, pk=self.kwargs['pk'])
-        return ConsoleServerPort.objects.filter(device=device).select_related('connected_console')
+        return Response(lldp_neighbors)
 
 
 #
-# Power ports
+# Device components
 #
 
-class PowerPortListView(generics.ListAPIView):
-    """
-    List power ports (by device)
-    """
-    serializer_class = serializers.PowerPortSerializer
+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
 
-    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 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 PowerPortView(generics.RetrieveUpdateDestroyAPIView):
-    permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
+class PowerPortViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
     serializer_class = serializers.PowerPortSerializer
-    queryset = PowerPort.objects.all()
-
+    write_serializer_class = serializers.WritablePowerPortSerializer
+    filter_class = filters.PowerPortFilter
 
-#
-# Power outlets
-#
 
-class PowerOutletListView(generics.ListAPIView):
-    """
-    List power outlets (by device)
-    """
+class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = PowerOutlet.objects.select_related('device', 'connected_port__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')
+    write_serializer_class = serializers.WritablePowerOutletSerializer
+    filter_class = filters.PowerOutletFilter
 
 
-#
-# Interfaces
-#
-
-class InterfaceListView(generics.ListAPIView):
-    """
-    List interfaces (by device)
-    """
+class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = Interface.objects.select_related('device')
     serializer_class = serializers.InterfaceSerializer
+    write_serializer_class = serializers.WritableInterfaceSerializer
     filter_class = filters.InterfaceFilter
 
-    def get_queryset(self):
+    @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)
 
-        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()
+class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = DeviceBay.objects.select_related('installed_device')
+    serializer_class = serializers.DeviceBaySerializer
+    write_serializer_class = serializers.WritableDeviceBaySerializer
+    filter_class = filters.DeviceBayFilter
 
-        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')
+class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = InventoryItem.objects.select_related('device', 'manufacturer')
+    serializer_class = serializers.InventoryItemSerializer
+    write_serializer_class = serializers.WritableInventoryItemSerializer
+    filter_class = filters.InventoryItemFilter
 
 
 #
-# Live queries
+# Connections
 #
 
-class LLDPNeighborsView(APIView):
-    """
-    Retrieve live LLDP neighbors of a device
-    """
-
-    def get(self, request, pk):
+class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
+    queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False)
+    serializer_class = serializers.ConsolePortSerializer
+    filter_class = filters.ConsoleConnectionFilter
 
-        device = get_object_or_404(Device, pk=pk)
-        if not device.primary_ip:
-            raise ServiceUnavailable(detail="No IP configured for this device.")
 
-        RPC = device.get_rpc_client()
-        if not RPC:
-            raise ServiceUnavailable(detail="No RPC client available for this platform ({}).".format(device.platform))
+class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
+    queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False)
+    serializer_class = serializers.PowerPortSerializer
+    filter_class = filters.PowerConnectionFilter
 
-        # Connect to device and retrieve inventory info
-        try:
-            with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client:
-                lldp_neighbors = rpc_client.get_lldp_neighbors()
-        except:
-            raise ServiceUnavailable(detail="Error connecting to the remote device.")
 
-        return Response(lldp_neighbors)
+class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
+    serializer_class = serializers.InterfaceConnectionSerializer
+    write_serializer_class = serializers.WritableInterfaceConnectionSerializer
+    filter_class = filters.InterfaceConnectionFilter
 
 
 #
 # Miscellaneous
 #
 
-class RelatedConnectionsView(APIView):
+class ConnectedDeviceViewSet(ViewSet):
     """
-    Retrieve all connections related to a given console/power/interface connection
+    This endpoint allows a user to determine what device (if any) is connected to a given peer device and peer
+    interface. This is useful in a situation where a device boots with no configuration, but can detect its neighbors
+    via a protocol such as LLDP. Two query parameters must be included in the request:
+
+    * `peer-device`: The name of the peer device
+    * `peer-interface`: The name of the peer interface
     """
+    permission_classes = [IsAuthenticated]
 
-    def __init__(self):
-        super(RelatedConnectionsView, self).__init__()
+    def get_view_name(self):
+        return "Connected Device Locator"
 
-        # Custom fields
-        self.content_type = ContentType.objects.get_for_model(Device)
-        self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
+    def list(self, request):
 
-    def get(self, request):
+        peer_device_name = request.query_params.get('peer-device')
+        peer_interface_name = request.query_params.get('peer-interface')
+        if not peer_device_name or not peer_interface_name:
+            raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
 
-        peer_device = request.GET.get('peer-device')
-        peer_interface = request.GET.get('peer-interface')
+        # Determine local interface from peer interface's connection
+        peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)
+        local_interface = peer_interface.connected_interface
 
-        # Search by interface
-        if peer_device and peer_interface:
+        if local_interface is None:
+            return Response()
 
-            # Determine local interface from peer interface's connection
-            try:
-                peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface)
-            except Interface.DoesNotExist:
-                raise Http404()
-            local_iface = peer_iface.connected_interface
-            if local_iface:
-                device = local_iface.device
-            else:
-                return Response()
-
-        else:
-            raise MissingFilterException(detail='Must specify search parameters "peer-device" and "peer-interface".')
-
-        # Initialize response skeleton
-        response = {
-            'device': serializers.DeviceSerializer(device, context={'view': self}).data,
-            'console-ports': [],
-            'power-ports': [],
-            'interfaces': [],
-        }
-
-        # Console connections
-        console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device')
-        for cp in console_ports:
-            data = serializers.ConsolePortSerializer(instance=cp).data
-            del(data['device'])
-            response['console-ports'].append(data)
-
-        # Power connections
-        power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device')
-        for pp in power_ports:
-            data = serializers.PowerPortSerializer(instance=pp).data
-            del(data['device'])
-            response['power-ports'].append(data)
-
-        # Interface connections
-        interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\
-            .select_related('connected_as_a', 'connected_as_b', 'circuit_termination')
-        for iface in interfaces:
-            data = serializers.InterfaceDetailSerializer(instance=iface).data
-            del(data['device'])
-            response['interfaces'].append(data)
-
-        return Response(response)
+        return Response(serializers.DeviceSerializer(local_interface.device, context={'request': request}).data)

+ 116 - 59
netbox/dcim/filters.py

@@ -7,9 +7,10 @@ from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
 from .models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection,
-    Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Region, Site,
-    VIRTUAL_IFACE_TYPES,
+    ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
+    DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
+    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
 )
 
 
@@ -230,6 +231,62 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
         )
 
 
+class DeviceTypeComponentFilterSet(django_filters.FilterSet):
+    devicetype_id = django_filters.ModelMultipleChoiceFilter(
+        name='device_type',
+        queryset=DeviceType.objects.all(),
+        label='Device type (ID)',
+    )
+    devicetype = django_filters.ModelMultipleChoiceFilter(
+        name='device_type',
+        queryset=DeviceType.objects.all(),
+        to_field_name='name',
+        label='Device type (name)',
+    )
+
+
+class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = ConsolePortTemplate
+        fields = ['name']
+
+
+class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = ConsoleServerPortTemplate
+        fields = ['name']
+
+
+class PowerPortTemplateFilter(DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = PowerPortTemplate
+        fields = ['name']
+
+
+class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = PowerOutletTemplate
+        fields = ['name']
+
+
+class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = InterfaceTemplate
+        fields = ['name', 'form_factor']
+
+
+class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = DeviceBayTemplate
+        fields = ['name']
+
+
 class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
     id__in = NumericInFilter(name='id', lookup_expr='in')
     q = django_filters.CharFilter(
@@ -316,10 +373,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         to_field_name='slug',
         label='Platform (slug)',
     )
-    status = django_filters.BooleanFilter(
-        name='status',
-        label='Status',
-    )
     is_console_server = django_filters.BooleanFilter(
         name='device_type__is_console_server',
         label='Is a console server',
@@ -332,6 +385,13 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         name='device_type__is_network_device',
         label='Is a network device',
     )
+    has_primary_ip = django_filters.BooleanFilter(
+        method='_has_primary_ip',
+        label='Has a primary IP',
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=STATUS_CHOICES
+    )
 
     class Meta:
         model = Device
@@ -343,7 +403,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         return queryset.filter(
             Q(name__icontains=value) |
             Q(serial__icontains=value.strip()) |
-            Q(modules__serial__icontains=value.strip()) |
+            Q(inventory_items__serial__icontains=value.strip()) |
             Q(asset_tag=value.strip()) |
             Q(comments__icontains=value)
         ).distinct()
@@ -357,91 +417,62 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         except AddrFormatError:
             return queryset.none()
 
+    def _has_primary_ip(self, queryset, name, value):
+        if value:
+            return queryset.filter(
+                Q(primary_ip4__isnull=False) |
+                Q(primary_ip6__isnull=False)
+            )
+        else:
+            return queryset.exclude(
+                Q(primary_ip4__isnull=False) |
+                Q(primary_ip6__isnull=False)
+            )
 
-class ConsolePortFilter(django_filters.FilterSet):
+
+class DeviceComponentFilterSet(django_filters.FilterSet):
     device_id = django_filters.ModelMultipleChoiceFilter(
         name='device',
         queryset=Device.objects.all(),
         label='Device (ID)',
     )
     device = django_filters.ModelMultipleChoiceFilter(
-        name='device',
+        name='device__name',
         queryset=Device.objects.all(),
         to_field_name='name',
         label='Device (name)',
     )
 
+
+class ConsolePortFilter(DeviceComponentFilterSet):
+
     class Meta:
         model = ConsolePort
         fields = ['name']
 
 
-class ConsoleServerPortFilter(django_filters.FilterSet):
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        name='device',
-        queryset=Device.objects.all(),
-        label='Device (ID)',
-    )
-    device = django_filters.ModelMultipleChoiceFilter(
-        name='device',
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        label='Device (name)',
-    )
+class ConsoleServerPortFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = ConsoleServerPort
         fields = ['name']
 
 
-class PowerPortFilter(django_filters.FilterSet):
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        name='device',
-        queryset=Device.objects.all(),
-        label='Device (ID)',
-    )
-    device = django_filters.ModelMultipleChoiceFilter(
-        name='device',
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        label='Device (name)',
-    )
+class PowerPortFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = PowerPort
         fields = ['name']
 
 
-class PowerOutletFilter(django_filters.FilterSet):
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        name='device',
-        queryset=Device.objects.all(),
-        label='Device (ID)',
-    )
-    device = django_filters.ModelMultipleChoiceFilter(
-        name='device',
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        label='Device (name)',
-    )
+class PowerOutletFilter(DeviceComponentFilterSet):
 
     class Meta:
         model = PowerOutlet
         fields = ['name']
 
 
-class InterfaceFilter(django_filters.FilterSet):
-    device_id = django_filters.ModelMultipleChoiceFilter(
-        name='device',
-        queryset=Device.objects.all(),
-        label='Device (ID)',
-    )
-    device = django_filters.ModelMultipleChoiceFilter(
-        name='device',
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        label='Device (name)',
-    )
+class InterfaceFilter(DeviceComponentFilterSet):
     type = django_filters.CharFilter(
         method='filter_type',
         label='Interface type',
@@ -453,7 +484,7 @@ class InterfaceFilter(django_filters.FilterSet):
 
     class Meta:
         model = Interface
-        fields = ['name']
+        fields = ['name', 'form_factor']
 
     def filter_type(self, queryset, name, value):
         value = value.strip().lower()
@@ -475,6 +506,20 @@ class InterfaceFilter(django_filters.FilterSet):
             return queryset.none()
 
 
+class DeviceBayFilter(DeviceComponentFilterSet):
+
+    class Meta:
+        model = DeviceBay
+        fields = ['name']
+
+
+class InventoryItemFilter(DeviceComponentFilterSet):
+
+    class Meta:
+        model = InventoryItem
+        fields = ['name']
+
+
 class ConsoleConnectionFilter(django_filters.FilterSet):
     site = django_filters.CharFilter(
         method='filter_site',
@@ -485,6 +530,10 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
         label='Device',
     )
 
+    class Meta:
+        model = ConsolePort
+        fields = ['name', 'connection_status']
+
     def filter_site(self, queryset, name, value):
         if not value.strip():
             return queryset
@@ -509,6 +558,10 @@ class PowerConnectionFilter(django_filters.FilterSet):
         label='Device',
     )
 
+    class Meta:
+        model = PowerPort
+        fields = ['name', 'connection_status']
+
     def filter_site(self, queryset, name, value):
         if not value.strip():
             return queryset
@@ -533,6 +586,10 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
         label='Device',
     )
 
+    class Meta:
+        model = InterfaceConnection
+        fields = ['connection_status']
+
     def filter_site(self, queryset, name, value):
         if not value.strip():
             return queryset

+ 50 - 48
netbox/dcim/forms.py

@@ -1,6 +1,5 @@
-import re
-
 from mptt.forms import TreeNodeChoiceField
+import re
 
 from django import forms
 from django.contrib.postgres.forms.array import SimpleArrayField
@@ -21,9 +20,9 @@ from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
     Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
-    Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
-    RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
-    SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES
+    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate,
+    RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES,
+    SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES,
 )
 
 
@@ -532,7 +531,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
 
     class Meta:
         model = Platform
-        fields = ['name', 'slug']
+        fields = ['name', 'slug', 'rpc_client']
 
 
 #
@@ -541,27 +540,32 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
 
 class DeviceForm(BootstrapMixin, CustomFieldForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
-    rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect(
-        api_url='/api/dcim/racks/?site_id={{site}}',
-        display_field='display_name',
-        attrs={'filter-for': 'position'}
-    ))
-    position = forms.TypedChoiceField(required=False, empty_value=None,
-                                      help_text="The lowest-numbered unit occupied by the device",
-                                      widget=APISelect(api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}',
-                                                       disabled_indicator='device'))
-    manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
-                                          widget=forms.Select(attrs={'filter-for': 'device_type'}))
-    device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Device type', widget=APISelect(
-        api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
-        display_field='model'
-    ))
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(), required=False, widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
+            display_field='display_name',
+            attrs={'filter-for': 'position'}
+        )
+    )
+    position = forms.TypedChoiceField(
+        required=False, empty_value=None, help_text="The lowest-numbered unit occupied by the device",
+        widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}', disabled_indicator='device')
+    )
+    manufacturer = forms.ModelChoiceField(
+        queryset=Manufacturer.objects.all(), widget=forms.Select(attrs={'filter-for': 'device_type'})
+    )
+    device_type = forms.ModelChoiceField(
+        queryset=DeviceType.objects.all(), label='Device type',
+        widget=APISelect(api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', display_field='model')
+    )
     comments = CommentField()
 
     class Meta:
         model = Device
-        fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position',
-                  'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments']
+        fields = [
+            'name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
+            'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments',
+        ]
         help_texts = {
             'device_role': "The function this device serves",
             'serial': "Chassis serial number",
@@ -773,6 +777,13 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['tenant', 'platform']
 
 
+def device_status_choices():
+    status_counts = {}
+    for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
+        status_counts[status['status']] = status['count']
+    return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
+
+
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
     q = forms.CharField(required=False, label='Search')
@@ -792,10 +803,7 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
         null_option=(0, 'None'),
     )
-    manufacturer_id = FilterChoiceField(
-        queryset=Manufacturer.objects.all(),
-        label='Manufacturer',
-    )
+    manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer')
     device_type_id = FilterChoiceField(
         queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate(
             filter_count=Count('instances'),
@@ -807,14 +815,8 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         null_option=(0, 'None'),
     )
-    status = forms.NullBooleanField(
-        required=False,
-        widget=forms.Select(choices=FORM_STATUS_CHOICES),
-    )
-    mac_address = forms.CharField(
-        required=False,
-        label='MAC address',
-    )
+    status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
+    mac_address = forms.CharField(required=False, label='MAC address')
 
 
 #
@@ -950,7 +952,7 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
         label='Console Server',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='console_server',
         )
     )
@@ -958,7 +960,7 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=ConsoleServerPort.objects.all(),
         label='Port',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{console_server}}/console-server-ports/',
+            api_url='/api/dcim/console-server-ports/?device_id={{device}}',
             disabled_indicator='connected_console',
         )
     )
@@ -1051,7 +1053,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
         )
     )
@@ -1059,7 +1061,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
         queryset=ConsolePort.objects.all(),
         label='Port',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/console-ports/',
+            api_url='/api/dcim/console-ports/?device_id={{device}}',
             disabled_indicator='cs_port'
         )
     )
@@ -1218,7 +1220,7 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
         label='PDU',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='pdu'
         )
     )
@@ -1226,7 +1228,7 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=PowerOutlet.objects.all(),
         label='Outlet',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{pdu}}/power-outlets/',
+            api_url='/api/dcim/power-outlets/?device_id={{device}}',
             disabled_indicator='connected_port'
         )
     )
@@ -1317,7 +1319,7 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device'
         )
     )
@@ -1325,7 +1327,7 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
         queryset=PowerPort.objects.all(),
         label='Port',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device}}/power-ports/',
+            api_url='/api/dcim/power-ports/?device_id={{device}}',
             disabled_indicator='power_outlet'
         )
     )
@@ -1488,7 +1490,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         label='Device',
         widget=Livesearch(
             query_key='q',
-            query_url='dcim-api:device_list',
+            query_url='dcim-api:device-list',
             field_to_update='device_b'
         )
     )
@@ -1496,7 +1498,7 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         queryset=Interface.objects.all(),
         label='Interface',
         widget=APISelect(
-            api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical',
+            api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
             disabled_indicator='is_connected'
         )
     )
@@ -1701,11 +1703,11 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
 
 
 #
-# Modules
+# Inventory items
 #
 
-class ModuleForm(BootstrapMixin, forms.ModelForm):
+class InventoryItemForm(BootstrapMixin, forms.ModelForm):
 
     class Meta:
-        model = Module
+        model = InventoryItem
         fields = ['name', 'manufacturer', 'part_id', 'serial']

+ 21 - 0
netbox/dcim/migrations/0033_rackreservation_rack_editable.py

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

+ 35 - 0
netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py

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

+ 27 - 0
netbox/dcim/migrations/0035_device_expand_status_choices.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.7 on 2017-05-08 15:57
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0034_rename_module_to_inventoryitem'),
+    ]
+
+    # We convert the BooleanField to an IntegerField first as PostgreSQL does not provide a direct cast for boolean to
+    # smallint (attempting to convert directly yields the error "cannot cast type boolean to smallint").
+    operations = [
+        migrations.AlterField(
+            model_name='device',
+            name='status',
+            field=models.PositiveIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='status',
+            field=models.PositiveSmallIntegerField(choices=[[1, b'Active'], [0, b'Offline'], [2, b'Planned'], [3, b'Staged'], [4, b'Failed'], [5, b'Inventory']], default=1, verbose_name=b'Status'),
+        ),
+    ]

+ 60 - 28
netbox/dcim/models.py

@@ -9,14 +9,14 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
-from django.core.urlresolvers import reverse
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
+from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 
 from circuits.models import Circuit
-from extras.models import CustomFieldModel, CustomField, CustomFieldValue
+from extras.models import CustomFieldModel, CustomField, CustomFieldValue, ImageAttachment
 from extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
 from utilities.fields import ColorField, NullableCharField
@@ -178,13 +178,30 @@ VIRTUAL_IFACE_TYPES = [
     IFACE_FF_LAG,
 ]
 
-STATUS_ACTIVE = True
-STATUS_OFFLINE = False
+STATUS_OFFLINE = 0
+STATUS_ACTIVE = 1
+STATUS_PLANNED = 2
+STATUS_STAGED = 3
+STATUS_FAILED = 4
+STATUS_INVENTORY = 5
 STATUS_CHOICES = [
     [STATUS_ACTIVE, 'Active'],
     [STATUS_OFFLINE, 'Offline'],
+    [STATUS_PLANNED, 'Planned'],
+    [STATUS_STAGED, 'Staged'],
+    [STATUS_FAILED, 'Failed'],
+    [STATUS_INVENTORY, 'Inventory'],
 ]
 
+DEVICE_STATUS_CLASSES = {
+    0: 'warning',
+    1: 'success',
+    2: 'info',
+    3: 'primary',
+    4: 'danger',
+    5: 'default',
+}
+
 CONNECTION_STATUS_PLANNED = False
 CONNECTION_STATUS_CONNECTED = True
 CONNECTION_STATUS_CHOICES = [
@@ -212,7 +229,9 @@ class Region(MPTTModel):
     """
     Sites can be grouped within geographic Regions.
     """
-    parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True)
+    parent = TreeForeignKey(
+        'self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.CASCADE
+    )
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
 
@@ -255,6 +274,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
     contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
     objects = SiteManager()
 
@@ -314,7 +334,7 @@ class RackGroup(models.Model):
     """
     name = models.CharField(max_length=50)
     slug = models.SlugField()
-    site = models.ForeignKey('Site', related_name='rack_groups')
+    site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
 
     class Meta:
         ordering = ['site', 'name']
@@ -376,6 +396,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
                                      help_text='Units are numbered top-to-bottom')
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
     objects = RackManager()
 
@@ -534,7 +555,7 @@ class RackReservation(models.Model):
     """
     One or more reserved units within a Rack.
     """
-    rack = models.ForeignKey('Rack', related_name='reservations', editable=False, on_delete=models.CASCADE)
+    rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
     units = ArrayField(models.PositiveSmallIntegerField())
     created = models.DateTimeField(auto_now_add=True)
     user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT)
@@ -916,11 +937,11 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
 
-    Each Device must be assigned to a Rack, although associating it with a particular rack face or unit is optional (for
-    example, vertically mounted PDUs do not consume rack units).
+    Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a
+    particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units).
 
-    When a new Device is created, console/power/interface components are created along with it as dictated by the
-    component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
+    When a new Device is created, console/power/interface/device bay components are created along with it as dictated
+    by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
     creation of a Device.
     """
     device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
@@ -929,21 +950,29 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
     platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
     name = NullableCharField(max_length=64, blank=True, null=True, unique=True)
     serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
-    asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
-                                  help_text='A unique tag used to identify this device')
+    asset_tag = NullableCharField(
+        max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
+        help_text='A unique tag used to identify this device'
+    )
     site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT)
     rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT)
-    position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
-                                                verbose_name='Position (U)',
-                                                help_text='The lowest-numbered unit occupied by the device')
+    position = models.PositiveSmallIntegerField(
+        blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)',
+        help_text='The lowest-numbered unit occupied by the device'
+    )
     face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
-    status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
-    primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL,
-                                       blank=True, null=True, verbose_name='Primary IPv4')
-    primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
-                                       blank=True, null=True, verbose_name='Primary IPv6')
+    status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
+    primary_ip4 = models.OneToOneField(
+        'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
+        verbose_name='Primary IPv4'
+    )
+    primary_ip6 = models.OneToOneField(
+        'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
+        verbose_name='Primary IPv6'
+    )
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
+    images = GenericRelation(ImageAttachment)
 
     objects = DeviceManager()
 
@@ -1103,6 +1132,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         """
         return Device.objects.filter(parent_bay__device=self.pk)
 
+    def get_status_class(self):
+        return DEVICE_STATUS_CLASSES[self.status]
+
     def get_rpc_client(self):
         """
         Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.
@@ -1409,19 +1441,19 @@ class DeviceBay(models.Model):
 
 
 #
-# Modules
+# Inventory items
 #
 
 @python_2_unicode_compatible
-class Module(models.Model):
+class InventoryItem(models.Model):
     """
-    A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only
-    for inventory purposes.
+    An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
+    InventoryItems are used only for inventory purposes.
     """
-    device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
-    parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE)
+    device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE)
+    parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE)
     name = models.CharField(max_length=50, verbose_name='Name')
-    manufacturer = models.ForeignKey('Manufacturer', related_name='modules', blank=True, null=True,
+    manufacturer = models.ForeignKey('Manufacturer', related_name='inventory_items', blank=True, null=True,
                                      on_delete=models.PROTECT)
     part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
     serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)

+ 84 - 32
netbox/dcim/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, SearchTable, ToggleColumn
 
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
@@ -92,12 +92,8 @@ DEVICE_ROLE = """
 <label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
 """
 
-STATUS_ICON = """
-{% if record.status %}
-    <span class="glyphicon glyphicon-ok-sign text-success" title="Active" aria-hidden="true"></span>
-{% else %}
-    <span class="glyphicon glyphicon-minus-sign text-danger" title="Offline" aria-hidden="true"></span>
-{% endif %}
+DEVICE_STATUS = """
+<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
 """
 
 DEVICE_PRIMARY_IP = """
@@ -142,11 +138,9 @@ class RegionTable(BaseTable):
 
 class SiteTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
-    facility = tables.Column(verbose_name='Facility')
-    region = tables.TemplateColumn(template_code=SITE_REGION_LINK, verbose_name='Region')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    asn = tables.Column(verbose_name='ASN')
+    name = tables.LinkColumn()
+    region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
     rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
     device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
     prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
@@ -161,6 +155,16 @@ class SiteTable(BaseTable):
         )
 
 
+class SiteSearchTable(SearchTable):
+    name = tables.LinkColumn()
+    region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(SearchTable.Meta):
+        model = Site
+        fields = ('name', 'facility', 'region', 'tenant', 'asn')
+
+
 #
 # Rack groups
 #
@@ -203,20 +207,33 @@ class RackRoleTable(BaseTable):
 
 class RackTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
+    name = tables.LinkColumn()
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    facility_id = tables.Column(verbose_name='Facility ID')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    role = tables.TemplateColumn(RACK_ROLE)
     u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
-    devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
+    devices = tables.Column(accessor=Accessor('device_count'))
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
 
     class Meta(BaseTable.Meta):
         model = Rack
-        fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices',
-                  'get_utilization')
+        fields = (
+            'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization'
+        )
+
+
+class RackSearchTable(SearchTable):
+    name = tables.LinkColumn()
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    role = tables.TemplateColumn(RACK_ROLE)
+    u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
+
+    class Meta(SearchTable.Meta):
+        model = Rack
+        fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height')
 
 
 class RackImportTable(BaseTable):
@@ -272,9 +289,7 @@ class ManufacturerTable(BaseTable):
 
 class DeviceTypeTable(BaseTable):
     pk = ToggleColumn()
-    manufacturer = tables.Column(verbose_name='Manufacturer')
     model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
-    part_number = tables.Column(verbose_name='Part Number')
     is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
     is_console_server = tables.BooleanColumn(verbose_name='CS')
     is_pdu = tables.BooleanColumn(verbose_name='PDU')
@@ -290,6 +305,22 @@ class DeviceTypeTable(BaseTable):
         )
 
 
+class DeviceTypeSearchTable(SearchTable):
+    model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
+    is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
+    is_console_server = tables.BooleanColumn(verbose_name='CS')
+    is_pdu = tables.BooleanColumn(verbose_name='PDU')
+    is_network_device = tables.BooleanColumn(verbose_name='Net')
+    subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
+
+    class Meta(SearchTable.Meta):
+        model = DeviceType
+        fields = (
+            'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
+            'is_network_device', 'subdevice_role',
+        )
+
+
 #
 # Device type components
 #
@@ -381,12 +412,13 @@ class PlatformTable(BaseTable):
     name = tables.LinkColumn(verbose_name='Name')
     device_count = tables.Column(verbose_name='Devices')
     slug = tables.Column(verbose_name='Slug')
+    rpc_client = tables.Column(accessor='get_rpc_client_display', orderable=False, verbose_name='RPC Client')
     actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
                                     verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = Platform
-        fields = ('pk', 'name', 'device_count', 'slug', 'actions')
+        fields = ('pk', 'name', 'device_count', 'slug', 'rpc_client', 'actions')
 
 
 #
@@ -395,22 +427,42 @@ class PlatformTable(BaseTable):
 
 class DeviceTable(BaseTable):
     pk = ToggleColumn()
-    status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
-    name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
-    rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
+    name = tables.TemplateColumn(template_code=DEVICE_LINK)
+    status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
     device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
-    device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
-                                    text=lambda record: record.device_type.full_name)
-    primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address',
-                                       template_code=DEVICE_PRIMARY_IP)
+    device_type = tables.LinkColumn(
+        'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
+        text=lambda record: record.device_type.full_name
+    )
+    primary_ip = tables.TemplateColumn(
+        orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP
+    )
 
     class Meta(BaseTable.Meta):
         model = Device
         fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
 
 
+class DeviceSearchTable(SearchTable):
+    name = tables.TemplateColumn(template_code=DEVICE_LINK)
+    status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
+    device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
+    device_type = tables.LinkColumn(
+        'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
+        text=lambda record: record.device_type.full_name
+    )
+
+    class Meta(SearchTable.Meta):
+        model = Device
+        fields = ('name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type')
+
+
 class DeviceImportTable(BaseTable):
     name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')

File diff suppressed because it is too large
+ 2158 - 0
netbox/dcim/tests/test_api.py


+ 0 - 676
netbox/dcim/tests/test_apis.py

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

+ 12 - 5
netbox/dcim/urls.py

@@ -3,9 +3,12 @@ from django.conf.urls import url
 from ipam.views import ServiceEditView
 from secrets.views import secret_add
 
+from extras.views import ImageAttachmentEditView
+from .models import Device, Rack, Site
 from . import views
 
 
+app_name = 'dcim'
 urlpatterns = [
 
     # Regions
@@ -22,6 +25,7 @@ urlpatterns = [
     url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
     url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
     url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
+    url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
 
     # Rack groups
     url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
@@ -43,6 +47,7 @@ urlpatterns = [
 
     # Racks
     url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
+    url(r'^rack-elevations/$', views.RackElevationListView.as_view(), name='rack_elevation_list'),
     url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
     url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'),
     url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
@@ -51,6 +56,7 @@ urlpatterns = [
     url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
     url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
     url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
+    url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
     # Manufacturers
     url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@@ -118,6 +124,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
+    url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
 
     # Console ports
     url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
@@ -174,6 +181,11 @@ urlpatterns = [
     url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
     url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
 
+    # Inventory items
+    url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
+    url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
+    url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
+
     # Console/power/interface connections
     url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
     url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
@@ -182,9 +194,4 @@ urlpatterns = [
     url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
     url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
 
-    # Modules
-    url(r'^devices/(?P<device>\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'),
-    url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
-    url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
-
 ]

+ 55 - 14
netbox/dcim/views.py

@@ -6,11 +6,11 @@ from operator import attrgetter
 from django.contrib import messages
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
-from django.core.urlresolvers import reverse
+from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db.models import Count
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
-from django.utils.html import escape
+from django.urls import reverse
 from django.utils.http import urlencode
 from django.utils.safestring import mark_safe
 from django.views.generic import View
@@ -19,6 +19,7 @@ from ipam.models import Prefix, Service, VLAN
 from circuits.models import Circuit
 from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE, UserAction
 from utilities.forms import ConfirmationForm
+from utilities.paginator import EnhancedPaginator
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
@@ -27,7 +28,7 @@ from . import filters, forms, tables
 from .models import (
     CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
-    Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
+    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
     RackReservation, RackRole, Region, Site,
 )
 
@@ -293,6 +294,46 @@ class RackListView(ObjectListView):
     template_name = 'dcim/rack_list.html'
 
 
+class RackElevationListView(View):
+    """
+    Display a set of rack elevations side-by-side.
+    """
+
+    def get(self, request):
+
+        racks = Rack.objects.select_related(
+            'site', 'group', 'tenant', 'role'
+        ).prefetch_related(
+            'devices__device_type'
+        )
+        racks = filters.RackFilter(request.GET, racks).qs
+        total_count = racks.count()
+
+        # Pagination
+        paginator = EnhancedPaginator(racks, 25)
+        page_number = request.GET.get('page', 1)
+        try:
+            page = paginator.page(page_number)
+        except PageNotAnInteger:
+            page = paginator.page(1)
+        except EmptyPage:
+            page = paginator.page(paginator.num_pages)
+
+        # Determine rack face
+        if request.GET.get('face') == '1':
+            face_id = 1
+        else:
+            face_id = 0
+
+        return render(request, 'dcim/rack_elevation_list.html', {
+            'paginator': paginator,
+            'page': page,
+            'total_count': total_count,
+            'face_id': face_id,
+            'filter_form': forms.RackFilterForm(request.GET),
+        })
+
+
 def rack(request, pk):
 
     rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
@@ -809,12 +850,12 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 def device_inventory(request, pk):
 
     device = get_object_or_404(Device, pk=pk)
-    modules = Module.objects.filter(device=device, parent=None).select_related('manufacturer')\
-        .prefetch_related('submodules')
+    inventory_items = InventoryItem.objects.filter(device=device, parent=None).select_related('manufacturer')\
+        .prefetch_related('child_items')
 
     return render(request, 'dcim/device_inventory.html', {
         'device': device,
-        'modules': modules,
+        'inventory_items': inventory_items,
     })
 
 
@@ -1636,13 +1677,13 @@ class InterfaceConnectionsListView(ObjectListView):
 
 
 #
-# Modules
+# Inventory items
 #
 
-class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
-    permission_required = 'dcim.change_module'
-    model = Module
-    form_class = forms.ModuleForm
+class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
+    permission_required = 'dcim.change_inventoryitem'
+    model = InventoryItem
+    form_class = forms.InventoryItemForm
 
     def alter_obj(self, obj, request, url_args, url_kwargs):
         if 'device' in url_kwargs:
@@ -1650,6 +1691,6 @@ class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
         return obj
 
 
-class ModuleDeleteView(PermissionRequiredMixin, ComponentDeleteView):
-    permission_required = 'dcim.delete_module'
-    model = Module
+class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+    permission_required = 'dcim.delete_inventoryitem'
+    model = InventoryItem

+ 131 - 0
netbox/extras/api/customfields.py

@@ -0,0 +1,131 @@
+from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
+
+from rest_framework import serializers
+from rest_framework.exceptions import ValidationError
+
+from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
+
+
+#
+# Custom fields
+#
+
+class CustomFieldsSerializer(serializers.BaseSerializer):
+
+    def to_representation(self, obj):
+        return obj
+
+    def to_internal_value(self, data):
+
+        content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
+        custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)}
+
+        for field_name, value in data.items():
+
+            # Validate custom field name
+            if field_name not in custom_fields:
+                raise ValidationError(u"Invalid custom field for {} objects: {}".format(content_type, field_name))
+
+            # Validate selected choice
+            cf = custom_fields[field_name]
+            if cf.type == CF_TYPE_SELECT:
+                valid_choices = [c.pk for c in cf.choices.all()]
+                if value not in valid_choices:
+                    raise ValidationError(u"Invalid choice ({}) for field {}".format(value, field_name))
+
+        # Check for missing required fields
+        missing_fields = []
+        for field_name, field in custom_fields.items():
+            if field.required and field_name not in data:
+                missing_fields.append(field_name)
+        if missing_fields:
+            raise ValidationError(u"Missing required fields: {}".format(u", ".join(missing_fields)))
+
+        return data
+
+
+class CustomFieldModelSerializer(serializers.ModelSerializer):
+    """
+    Extends ModelSerializer to render any CustomFields and their values associated with an object.
+    """
+    custom_fields = CustomFieldsSerializer(required=False)
+
+    def __init__(self, *args, **kwargs):
+
+        def _populate_custom_fields(instance, fields):
+            custom_fields = {f.name: None for f in fields}
+            for cfv in instance.custom_field_values.all():
+                if cfv.field.type == CF_TYPE_SELECT:
+                    custom_fields[cfv.field.name] = CustomFieldChoiceSerializer(cfv.value).data
+                else:
+                    custom_fields[cfv.field.name] = cfv.value
+            instance.custom_fields = custom_fields
+
+        super(CustomFieldModelSerializer, self).__init__(*args, **kwargs)
+
+        if self.instance is not None:
+
+            # Retrieve the set of CustomFields which apply to this type of object
+            content_type = ContentType.objects.get_for_model(self.Meta.model)
+            fields = CustomField.objects.filter(obj_type=content_type)
+
+            # Populate CustomFieldValues for each instance from database
+            try:
+                for obj in self.instance:
+                    _populate_custom_fields(obj, fields)
+            except TypeError:
+                _populate_custom_fields(self.instance, fields)
+
+    def _save_custom_fields(self, instance, custom_fields):
+        content_type = ContentType.objects.get_for_model(self.Meta.model)
+        for field_name, value in custom_fields.items():
+            custom_field = CustomField.objects.get(name=field_name)
+            CustomFieldValue.objects.update_or_create(
+                field=custom_field,
+                obj_type=content_type,
+                obj_id=instance.pk,
+                defaults={'serialized_value': value},
+            )
+
+    def create(self, validated_data):
+
+        custom_fields = validated_data.pop('custom_fields', None)
+
+        with transaction.atomic():
+
+            instance = super(CustomFieldModelSerializer, self).create(validated_data)
+
+            # Save custom fields
+            if custom_fields is not None:
+                self._save_custom_fields(instance, custom_fields)
+                instance.custom_fields = custom_fields
+
+        return instance
+
+    def update(self, instance, validated_data):
+
+        custom_fields = validated_data.pop('custom_fields', None)
+
+        with transaction.atomic():
+
+            instance = super(CustomFieldModelSerializer, self).update(instance, validated_data)
+
+            # Save custom fields
+            if custom_fields is not None:
+                self._save_custom_fields(instance, custom_fields)
+                instance.custom_fields = custom_fields
+
+        return instance
+
+
+class CustomFieldChoiceSerializer(serializers.ModelSerializer):
+    """
+    Imitate utilities.api.ChoiceFieldSerializer
+    """
+    value = serializers.IntegerField(source='pk')
+    label = serializers.CharField(source='value')
+
+    class Meta:
+        model = CustomFieldChoice
+        fields = ['value', 'label']

+ 0 - 88
netbox/extras/api/renderers.py

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

+ 111 - 32
netbox/extras/api/serializers.py

@@ -1,56 +1,135 @@
 from rest_framework import serializers
 
-from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph
+from django.core.exceptions import ObjectDoesNotExist
 
+from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
+from dcim.models import Device, Rack, Site
+from extras.models import (
+    ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
+)
+from users.api.serializers import NestedUserSerializer
+from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer
 
-class CustomFieldSerializer(serializers.Serializer):
-    """
-    Extends a ModelSerializer to render any CustomFields and their values associated with an object.
-    """
-    custom_fields = serializers.SerializerMethodField()
 
-    def get_custom_fields(self, obj):
+#
+# Graphs
+#
 
-        # 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
+class GraphSerializer(serializers.ModelSerializer):
+    type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
 
-        return fields
+    class Meta:
+        model = Graph
+        fields = ['id', 'type', 'weight', 'name', 'source', 'link']
 
 
-class CustomFieldChoiceSerializer(serializers.ModelSerializer):
+class WritableGraphSerializer(serializers.ModelSerializer):
 
     class Meta:
-        model = CustomFieldChoice
-        fields = ['id', 'value']
+        model = Graph
+        fields = ['id', 'type', 'weight', 'name', 'source', 'link']
 
 
-class GraphSerializer(serializers.ModelSerializer):
+class RenderedGraphSerializer(serializers.ModelSerializer):
     embed_url = serializers.SerializerMethodField()
     embed_link = serializers.SerializerMethodField()
+    type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
 
     class Meta:
         model = Graph
-        fields = ['name', 'embed_url', 'embed_link']
+        fields = ['id', 'type', 'weight', 'name', 'embed_url', 'embed_link']
 
     def get_embed_url(self, obj):
         return obj.embed_url(self.context['graphed_object'])
 
     def get_embed_link(self, obj):
         return obj.embed_link(self.context['graphed_object'])
+
+
+#
+# Export templates
+#
+
+class ExportTemplateSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = ExportTemplate
+        fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
+
+
+#
+# Topology maps
+#
+
+class TopologyMapSerializer(serializers.ModelSerializer):
+    site = NestedSiteSerializer()
+
+    class Meta:
+        model = TopologyMap
+        fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
+
+
+class WritableTopologyMapSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = TopologyMap
+        fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
+
+
+#
+# Image attachments
+#
+
+class ImageAttachmentSerializer(serializers.ModelSerializer):
+    parent = serializers.SerializerMethodField()
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created']
+
+    def get_parent(self, obj):
+
+        # Static mapping of models to their nested serializers
+        if isinstance(obj.parent, Device):
+            serializer = NestedDeviceSerializer
+        elif isinstance(obj.parent, Rack):
+            serializer = NestedRackSerializer
+        elif isinstance(obj.parent, Site):
+            serializer = NestedSiteSerializer
+        else:
+            raise Exception("Unexpected type of parent object for ImageAttachment")
+
+        return serializer(obj.parent, context={'request': self.context['request']}).data
+
+
+class WritableImageAttachmentSerializer(serializers.ModelSerializer):
+    content_type = ContentTypeFieldSerializer()
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['id', 'content_type', 'object_id', 'name', 'image']
+
+    def validate(self, data):
+
+        # Validate that the parent object exists
+        try:
+            data['content_type'].get_object_for_this_type(id=data['object_id'])
+        except ObjectDoesNotExist:
+            raise serializers.ValidationError(
+                "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
+            )
+
+        return data
+
+
+#
+# User actions
+#
+
+class UserActionSerializer(serializers.ModelSerializer):
+    user = NestedUserSerializer()
+    action = ChoiceFieldSerializer(choices=ACTION_CHOICES)
+
+    class Meta:
+        model = UserAction
+        fields = ['id', 'time', 'user', 'action', 'message']

+ 33 - 0
netbox/extras/api/urls.py

@@ -0,0 +1,33 @@
+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)
+
+# Image attachments
+router.register(r'image-attachments', views.ImageAttachmentViewSet)
+
+# Recent activity
+router.register(r'recent-activity', views.RecentActivityViewSet)
+
+app_name = 'extras-api'
+urlpatterns = router.urls

+ 63 - 83
netbox/extras/api/views.py

@@ -1,115 +1,95 @@
-import graphviz
-from rest_framework import generics
-from rest_framework.views import APIView
+from rest_framework.decorators import detail_route
+from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
-from django.http import Http404, HttpResponse
+from django.http import HttpResponse
 from django.shortcuts import get_object_or_404
 
-from circuits.models import Provider
-from dcim.models import Site, Device, Interface, InterfaceConnection
-from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE
+from extras import filters
+from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction
+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):
-        super(CustomFieldModelAPIView, self).__init__()
-        self.content_type = ContentType.objects.get_for_model(self.queryset.model)
-        self.custom_fields = self.content_type.custom_fields.prefetch_related('choices')
+    def get_serializer_context(self):
+
+        # Gather all custom fields for the model
+        content_type = ContentType.objects.get_for_model(self.queryset.model)
+        custom_fields = content_type.custom_fields.prefetch_related('choices')
 
         # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object.
         custom_field_choices = {}
-        for field in self.custom_fields:
+        for field in custom_fields:
             for cfc in field.choices.all():
                 custom_field_choices[cfc.id] = cfc.value
-        self.custom_field_choices = custom_field_choices
-
-
-class GraphListView(generics.ListAPIView):
-    """
-    Returns a list of relevant graphs
-    """
-    serializer_class = GraphSerializer
+        custom_field_choices = custom_field_choices
 
-    def get_serializer_context(self):
-        cls = {
-            GRAPH_TYPE_INTERFACE: Interface,
-            GRAPH_TYPE_PROVIDER: Provider,
-            GRAPH_TYPE_SITE: Site,
-        }
-        context = super(GraphListView, self).get_serializer_context()
-        context.update({'graphed_object': get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk'])})
+        context = super(CustomFieldModelViewSet, self).get_serializer_context()
+        context.update({
+            'custom_fields': custom_fields,
+            'custom_field_choices': custom_field_choices,
+        })
         return context
 
     def get_queryset(self):
-        graph_type = self.kwargs.get('type', None)
-        if not graph_type:
-            raise Http404()
-        queryset = Graph.objects.filter(type=graph_type)
-        return queryset
+        # Prefetch custom field values
+        return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field')
 
 
-class TopologyMapView(APIView):
-    """
-    Generate a topology diagram
-    """
+class GraphViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = Graph.objects.all()
+    serializer_class = serializers.GraphSerializer
+    write_serializer_class = serializers.WritableGraphSerializer
+    filter_class = filters.GraphFilter
 
-    def get(self, request, slug):
 
-        tmap = get_object_or_404(TopologyMap, slug=slug)
+class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = ExportTemplate.objects.all()
+    serializer_class = serializers.ExportTemplateSerializer
+    filter_class = filters.ExportTemplateFilter
 
-        # Construct the graph
-        graph = graphviz.Graph()
-        graph.graph_attr['ranksep'] = '1'
-        for i, device_set in enumerate(tmap.device_sets):
 
-            subgraph = graphviz.Graph(name='sg{}'.format(i))
-            subgraph.graph_attr['rank'] = 'same'
+class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = TopologyMap.objects.select_related('site')
+    serializer_class = serializers.TopologyMapSerializer
+    write_serializer_class = serializers.WritableTopologyMapSerializer
+    filter_class = filters.TopologyMapFilter
 
-            # Add a pseudonode for each device_set to enforce hierarchical layout
-            subgraph.node('set{}'.format(i), label='', shape='none', width='0')
-            if i:
-                graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
+    @detail_route()
+    def render(self, request, pk):
 
-            # Add each device to the graph
-            devices = []
-            for query in device_set.split(';'):  # Split regexes on semicolons
-                devices += Device.objects.filter(name__regex=query)
-            for d in devices:
-                subgraph.node(d.name)
+        tmap = get_object_or_404(TopologyMap, pk=pk)
+        img_format = 'png'
 
-            # Add an invisible connection to each successive device in a set to enforce horizontal order
-            for j in range(0, len(devices) - 1):
-                subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
+        try:
+            data = tmap.render(img_format=img_format)
+        except:
+            return HttpResponse(
+                "There was an error generating the requested graph. Ensure that the GraphViz executables have been "
+                "installed correctly."
+            )
 
-            graph.subgraph(subgraph)
+        response = HttpResponse(data, content_type='image/{}'.format(img_format))
+        response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, img_format)
 
-        # 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)
+        return response
 
-        # Add all connections to the graph
-        devices = Device.objects.filter(*(device_superset,))
-        connections = InterfaceConnection.objects.filter(interface_a__device__in=devices,
-                                                         interface_b__device__in=devices)
-        for c in connections:
-            graph.edge(c.interface_a.device.name, c.interface_b.device.name)
 
-        # Get the image data and return
-        try:
-            topo_data = graph.pipe(format='png')
-        except:
-            return HttpResponse("There was an error generating the requested graph. Ensure that the GraphViz "
-                                "executables have been installed correctly.")
-        response = HttpResponse(topo_data, content_type='image/png')
+class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = ImageAttachment.objects.all()
+    serializer_class = serializers.ImageAttachmentSerializer
+    write_serializer_class = serializers.WritableImageAttachmentSerializer
 
-        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

+ 47 - 1
netbox/extras/filters.py

@@ -1,8 +1,10 @@
 import django_filters
 
+from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 
-from .models import CF_TYPE_SELECT, CustomField
+from dcim.models import Site
+from .models import CF_TYPE_SELECT, CustomField, Graph, ExportTemplate, TopologyMap, UserAction
 
 
 class CustomFieldFilter(django_filters.Filter):
@@ -44,3 +46,47 @@ class CustomFieldFilterSet(django_filters.FilterSet):
         custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True)
         for cf in custom_fields:
             self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type)
+
+
+class GraphFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = Graph
+        fields = ['type', 'name']
+
+
+class ExportTemplateFilter(django_filters.FilterSet):
+
+    class Meta:
+        model = ExportTemplate
+        fields = ['content_type', 'name']
+
+
+class TopologyMapFilter(django_filters.FilterSet):
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        name='site',
+        queryset=Site.objects.all(),
+        label='Site',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        name='site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site (slug)',
+    )
+
+    class Meta:
+        model = TopologyMap
+        fields = ['name', 'slug']
+
+
+class UserActionFilter(django_filters.FilterSet):
+    username = django_filters.ModelMultipleChoiceFilter(
+        name='user__username',
+        queryset=User.objects.all(),
+        to_field_name='username',
+    )
+
+    class Meta:
+        model = UserAction
+        fields = ['user']

+ 10 - 2
netbox/extras/forms.py

@@ -3,9 +3,10 @@ from collections import OrderedDict
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 
-from utilities.forms import BulkEditForm, LaxURLField
+from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField
 from .models import (
-    CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue
+    CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CustomField, CustomFieldValue,
+    ImageAttachment,
 )
 
 
@@ -158,3 +159,10 @@ class CustomFieldFilterForm(forms.Form):
         for name, field in custom_fields:
             field.required = False
             self.fields[name] = field
+
+
+class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = ImageAttachment
+        fields = ['name', 'image']

+ 14 - 14
netbox/extras/management/commands/run_inventory.py

@@ -6,7 +6,7 @@ from django.conf import settings
 from django.core.management.base import BaseCommand, CommandError
 from django.db import transaction
 
-from dcim.models import Device, Module, Site
+from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
 
 
 class Command(BaseCommand):
@@ -25,12 +25,12 @@ class Command(BaseCommand):
 
     def handle(self, *args, **options):
 
-        def create_modules(modules, parent=None):
-            for module in modules:
-                m = Module(device=device, parent=parent, name=module['name'], part_id=module['part_id'],
-                           serial=module['serial'], discovered=True)
-                m.save()
-                create_modules(module.get('modules', []), parent=m)
+        def create_inventory_items(inventory_items, parent=None):
+            for item in inventory_items:
+                i = InventoryItem(device=device, parent=parent, name=item['name'], part_id=item['part_id'],
+                                  serial=item['serial'], discovered=True)
+                i.save()
+                create_inventory_items(item.get('items', []), parent=i)
 
         # Credentials
         if options['username']:
@@ -39,7 +39,7 @@ class Command(BaseCommand):
             self.password = getpass("Password: ")
 
         # Attempt to inventory only active devices
-        device_list = Device.objects.filter(status=True)
+        device_list = Device.objects.filter(status=STATUS_ACTIVE)
 
         # --site: Include only devices belonging to specified site(s)
         if options['site']:
@@ -72,7 +72,7 @@ class Command(BaseCommand):
 
             # Skip inactive devices
             if not device.status:
-                self.stdout.write("Skipped (inactive)")
+                self.stdout.write("Skipped (not active)")
                 continue
 
             # Skip devices without primary_ip set
@@ -107,9 +107,9 @@ class Command(BaseCommand):
                 self.stdout.write("")
                 self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial']))
                 self.stdout.write("\tDescription: {}".format(inventory['chassis']['description']))
-                for module in inventory['modules']:
-                    self.stdout.write("\tModule: {} / {} ({})".format(module['name'], module['part_id'],
-                                                                      module['serial']))
+                for item in inventory['items']:
+                    self.stdout.write("\tItem: {} / {} ({})".format(item['name'], item['part_id'],
+                                                                    item['serial']))
             else:
                 self.stdout.write("{} ({})".format(inventory['chassis']['description'], inventory['chassis']['serial']))
 
@@ -119,7 +119,7 @@ class Command(BaseCommand):
                     if device.serial != inventory['chassis']['serial']:
                         device.serial = inventory['chassis']['serial']
                         device.save()
-                    Module.objects.filter(device=device, discovered=True).delete()
-                    create_modules(inventory.get('modules', []))
+                    InventoryItem.objects.filter(device=device, discovered=True).delete()
+                    create_inventory_items(inventory.get('items', []))
 
         self.stdout.write("Finished!")

+ 34 - 0
netbox/extras/migrations/0006_add_imageattachments.py

@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-04-04 19:58
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import extras.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('extras', '0005_useraction_add_bulk_create'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ImageAttachment',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('object_id', models.PositiveIntegerField()),
+                ('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')),
+                ('image_height', models.PositiveSmallIntegerField()),
+                ('image_width', models.PositiveSmallIntegerField()),
+                ('name', models.CharField(blank=True, max_length=50)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+    ]

+ 147 - 7
netbox/extras/models.py

@@ -1,11 +1,13 @@
 from collections import OrderedDict
 from datetime import date
+import graphviz
 
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.core.validators import ValidationError
 from django.db import models
+from django.db.models import Q
 from django.http import HttpResponse
 from django.template import Template, Context
 from django.utils.encoding import python_2_unicode_compatible
@@ -68,6 +70,10 @@ ACTION_CHOICES = (
 )
 
 
+#
+# Custom fields
+#
+
 class CustomFieldModel(object):
 
     def cf(self):
@@ -150,16 +156,13 @@ class CustomField(models.Model):
             # Read date as YYYY-MM-DD
             return date(*[int(n) for n in serialized_value.split('-')])
         if self.type == CF_TYPE_SELECT:
-            try:
-                return self.choices.get(pk=int(serialized_value))
-            except CustomFieldChoice.DoesNotExist:
-                return None
+            return self.choices.get(pk=int(serialized_value))
         return serialized_value
 
 
 @python_2_unicode_compatible
 class CustomFieldValue(models.Model):
-    field = models.ForeignKey('CustomField', related_name='values')
+    field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE)
     obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
     obj_id = models.PositiveIntegerField()
     obj = GenericForeignKey('obj_type', 'obj_id')
@@ -213,6 +216,10 @@ class CustomFieldChoice(models.Model):
         CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
 
 
+#
+# Graphs
+#
+
 @python_2_unicode_compatible
 class Graph(models.Model):
     type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
@@ -238,9 +245,15 @@ class Graph(models.Model):
         return template.render(Context({'obj': obj}))
 
 
+#
+# Export templates
+#
+
 @python_2_unicode_compatible
 class ExportTemplate(models.Model):
-    content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
+    content_type = models.ForeignKey(
+        ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}, on_delete=models.CASCADE
+    )
     name = models.CharField(max_length=100)
     description = models.CharField(max_length=200, blank=True)
     template_code = models.TextField()
@@ -272,11 +285,15 @@ class ExportTemplate(models.Model):
         return response
 
 
+#
+# Topology maps
+#
+
 @python_2_unicode_compatible
 class TopologyMap(models.Model):
     name = models.CharField(max_length=50, unique=True)
     slug = models.SlugField(unique=True)
-    site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
+    site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
     device_patterns = models.TextField(
         help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
                   "result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@@ -296,6 +313,129 @@ class TopologyMap(models.Model):
             return None
         return [line.strip() for line in self.device_patterns.split('\n')]
 
+    def render(self, img_format='png'):
+
+        from circuits.models import CircuitTermination
+        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).select_related('device_role')
+            for d in devices:
+                fillcolor = '#{}'.format(d.device_role.color)
+                subgraph.node(d.name, style='filled', fillcolor=fillcolor)
+
+            # 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 interface 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)
+
+        # Add all circuits to the graph
+        for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
+            peer_termination = termination.get_peer_termination()
+            if peer_termination is not None and peer_termination.interface.device in devices:
+                graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
+
+        return graph.pipe(format=img_format)
+
+
+#
+# Image attachments
+#
+
+def image_upload(instance, filename):
+
+    path = 'image-attachments/'
+
+    # Rename the file to the provided name, if any. Attempt to preserve the file extension.
+    extension = filename.rsplit('.')[-1]
+    if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
+        filename = '.'.join([instance.name, extension])
+    elif instance.name:
+        filename = instance.name
+
+    return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
+
+
+@python_2_unicode_compatible
+class ImageAttachment(models.Model):
+    """
+    An uploaded image which is associated with an object.
+    """
+    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+    object_id = models.PositiveIntegerField()
+    parent = GenericForeignKey('content_type', 'object_id')
+    image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width')
+    image_height = models.PositiveSmallIntegerField()
+    image_width = models.PositiveSmallIntegerField()
+    name = models.CharField(max_length=50, blank=True)
+    created = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        if self.name:
+            return self.name
+        filename = self.image.name.rsplit('/', 1)[-1]
+        return filename.split('_', 2)[2]
+
+    def delete(self, *args, **kwargs):
+
+        _name = self.image.name
+
+        super(ImageAttachment, self).delete(*args, **kwargs)
+
+        # Delete file from disk
+        self.image.delete(save=False)
+
+        # Deleting the file erases its name. We restore the image's filename here in case we still need to reference it
+        # before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
+        self.image.name = _name
+
+    @property
+    def size(self):
+        """
+        Wrapper around `image.size` to suppress an OSError in case the file is inaccessible.
+        """
+        try:
+            return self.image.size
+        except OSError:
+            return None
+
+
+#
+# User actions
+#
 
 class UserActionManager(models.Manager):
 

+ 27 - 24
netbox/extras/rpc.py

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

+ 168 - 0
netbox/extras/tests/test_api.py

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

+ 214 - 1
netbox/extras/tests/test_customfields.py

@@ -1,7 +1,12 @@
 from datetime import date
 
+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.test import TestCase
+from django.urls import reverse
 
 from dcim.models import Site
 
@@ -9,9 +14,11 @@ from extras.models import (
     CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
     CF_TYPE_SELECT, CF_TYPE_URL,
 )
+from users.models import Token
+from utilities.tests import HttpStatusMixin
 
 
-class CustomFieldTestCase(TestCase):
+class CustomFieldTest(TestCase):
 
     def setUp(self):
 
@@ -95,3 +102,209 @@ class CustomFieldTestCase(TestCase):
 
         # Delete the custom field
         cf.delete()
+
+
+class CustomFieldAPITest(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)}
+
+        content_type = ContentType.objects.get_for_model(Site)
+
+        # Text custom field
+        self.cf_text = CustomField(type=CF_TYPE_TEXT, name='magic_word')
+        self.cf_text.save()
+        self.cf_text.obj_type = [content_type]
+        self.cf_text.save()
+
+        # Integer custom field
+        self.cf_integer = CustomField(type=CF_TYPE_INTEGER, name='magic_number')
+        self.cf_integer.save()
+        self.cf_integer.obj_type = [content_type]
+        self.cf_integer.save()
+
+        # Boolean custom field
+        self.cf_boolean = CustomField(type=CF_TYPE_BOOLEAN, name='is_magic')
+        self.cf_boolean.save()
+        self.cf_boolean.obj_type = [content_type]
+        self.cf_boolean.save()
+
+        # Date custom field
+        self.cf_date = CustomField(type=CF_TYPE_DATE, name='magic_date')
+        self.cf_date.save()
+        self.cf_date.obj_type = [content_type]
+        self.cf_date.save()
+
+        # URL custom field
+        self.cf_url = CustomField(type=CF_TYPE_URL, name='magic_url')
+        self.cf_url.save()
+        self.cf_url.obj_type = [content_type]
+        self.cf_url.save()
+
+        # Select custom field
+        self.cf_select = CustomField(type=CF_TYPE_SELECT, name='magic_choice')
+        self.cf_select.save()
+        self.cf_select.obj_type = [content_type]
+        self.cf_select.save()
+        self.cf_select_choice1 = CustomFieldChoice(field=self.cf_select, value='Foo')
+        self.cf_select_choice1.save()
+        self.cf_select_choice2 = CustomFieldChoice(field=self.cf_select, value='Bar')
+        self.cf_select_choice2.save()
+        self.cf_select_choice3 = CustomFieldChoice(field=self.cf_select, value='Baz')
+        self.cf_select_choice3.save()
+
+        self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+
+    def test_get_obj_without_custom_fields(self):
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.site.name)
+        self.assertEqual(response.data['custom_fields'], {
+            'magic_word': None,
+            'magic_number': None,
+            'is_magic': None,
+            'magic_date': None,
+            'magic_url': None,
+            'magic_choice': None,
+        })
+
+    def test_get_obj_with_custom_fields(self):
+
+        CUSTOM_FIELD_VALUES = [
+            (self.cf_text, 'Test string'),
+            (self.cf_integer, 1234),
+            (self.cf_boolean, True),
+            (self.cf_date, date(2016, 6, 23)),
+            (self.cf_url, 'http://example.com/'),
+            (self.cf_select, self.cf_select_choice1.pk),
+        ]
+        for field, value in CUSTOM_FIELD_VALUES:
+            cfv = CustomFieldValue(field=field, obj=self.site)
+            cfv.value = value
+            cfv.save()
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.site.name)
+        self.assertEqual(response.data['custom_fields'].get('magic_word'), CUSTOM_FIELD_VALUES[0][1])
+        self.assertEqual(response.data['custom_fields'].get('magic_number'), CUSTOM_FIELD_VALUES[1][1])
+        self.assertEqual(response.data['custom_fields'].get('is_magic'), CUSTOM_FIELD_VALUES[2][1])
+        self.assertEqual(response.data['custom_fields'].get('magic_date'), CUSTOM_FIELD_VALUES[3][1])
+        self.assertEqual(response.data['custom_fields'].get('magic_url'), CUSTOM_FIELD_VALUES[4][1])
+        self.assertEqual(response.data['custom_fields'].get('magic_choice'), {
+            'value': self.cf_select_choice1.pk, 'label': 'Foo'
+        })
+
+    def test_set_custom_field_text(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_word': 'Foo bar baz',
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_word'), data['custom_fields']['magic_word'])
+        cfv = self.site.custom_field_values.get(field=self.cf_text)
+        self.assertEqual(cfv.value, data['custom_fields']['magic_word'])
+
+    def test_set_custom_field_integer(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_number': 42,
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_number'), data['custom_fields']['magic_number'])
+        cfv = self.site.custom_field_values.get(field=self.cf_integer)
+        self.assertEqual(cfv.value, data['custom_fields']['magic_number'])
+
+    def test_set_custom_field_boolean(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'is_magic': 0,
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('is_magic'), data['custom_fields']['is_magic'])
+        cfv = self.site.custom_field_values.get(field=self.cf_boolean)
+        self.assertEqual(cfv.value, data['custom_fields']['is_magic'])
+
+    def test_set_custom_field_date(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_date': '2017-04-25',
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_date'), data['custom_fields']['magic_date'])
+        cfv = self.site.custom_field_values.get(field=self.cf_date)
+        self.assertEqual(cfv.value.isoformat(), data['custom_fields']['magic_date'])
+
+    def test_set_custom_field_url(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_url': 'http://example.com/2/',
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_url'), data['custom_fields']['magic_url'])
+        cfv = self.site.custom_field_values.get(field=self.cf_url)
+        self.assertEqual(cfv.value, data['custom_fields']['magic_url'])
+
+    def test_set_custom_field_select(self):
+
+        data = {
+            'name': 'Test Site 1',
+            'slug': 'test-site-1',
+            'custom_fields': {
+                'magic_choice': self.cf_select_choice2.pk,
+            }
+        }
+
+        url = reverse('dcim-api:site-detail', kwargs={'pk': self.site.pk})
+        response = self.client.put(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
+        cfv = self.site.custom_field_values.get(field=self.cf_select)
+        self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])

+ 13 - 0
netbox/extras/urls.py

@@ -0,0 +1,13 @@
+from django.conf.urls import url
+
+from extras import views
+
+
+app_name = 'extras'
+urlpatterns = [
+
+    # Image attachments
+    url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
+    url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
+
+]

+ 30 - 0
netbox/extras/views.py

@@ -0,0 +1,30 @@
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.shortcuts import get_object_or_404
+
+from utilities.views import ObjectDeleteView, ObjectEditView
+from .forms import ImageAttachmentForm
+from .models import ImageAttachment
+
+
+class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'extras.change_imageattachment'
+    model = ImageAttachment
+    form_class = ImageAttachmentForm
+
+    def alter_obj(self, imageattachment, request, args, kwargs):
+        if not imageattachment.pk:
+            # Assign the parent object based on URL kwargs
+            model = kwargs.get('model')
+            imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
+        return imageattachment
+
+    def get_return_url(self, request, imageattachment):
+        return imageattachment.parent.get_absolute_url()
+
+
+class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'dcim.delete_imageattachment'
+    model = ImageAttachment
+
+    def get_return_url(self, request, imageattachment):
+        return imageattachment.parent.get_absolute_url()

+ 0 - 81
netbox/ipam/admin.py

@@ -1,81 +0,0 @@
-from django.contrib import admin
-
-from .models import (
-    Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF,
-)
-
-
-@admin.register(VRF)
-class VRFAdmin(admin.ModelAdmin):
-    list_display = ['name', 'rd', 'tenant', 'enforce_unique']
-    list_filter = ['tenant']
-
-    def get_queryset(self, request):
-        qs = super(VRFAdmin, self).get_queryset(request)
-        return qs.select_related('tenant')
-
-
-@admin.register(Role)
-class RoleAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug', 'weight']
-
-
-@admin.register(RIR)
-class RIRAdmin(admin.ModelAdmin):
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-    list_display = ['name', 'slug', 'is_private']
-
-
-@admin.register(Aggregate)
-class AggregateAdmin(admin.ModelAdmin):
-    list_display = ['prefix', 'rir', 'date_added']
-    list_filter = ['family', 'rir']
-    search_fields = ['prefix']
-
-
-@admin.register(Prefix)
-class PrefixAdmin(admin.ModelAdmin):
-    list_display = ['prefix', 'vrf', 'tenant', 'site', 'status', 'role', 'vlan']
-    list_filter = ['family', 'site', 'status', 'role']
-    search_fields = ['prefix']
-
-    def get_queryset(self, request):
-        qs = super(PrefixAdmin, self).get_queryset(request)
-        return qs.select_related('vrf', 'site', 'role', 'vlan')
-
-
-@admin.register(IPAddress)
-class IPAddressAdmin(admin.ModelAdmin):
-    list_display = ['address', 'vrf', 'tenant', 'nat_inside']
-    list_filter = ['family']
-    fields = ['address', 'vrf', 'device', 'interface', 'nat_inside']
-    readonly_fields = ['interface', 'device', 'nat_inside']
-    search_fields = ['address']
-
-    def get_queryset(self, request):
-        qs = super(IPAddressAdmin, self).get_queryset(request)
-        return qs.select_related('vrf', 'nat_inside')
-
-
-@admin.register(VLANGroup)
-class VLANGroupAdmin(admin.ModelAdmin):
-    list_display = ['name', 'site', 'slug']
-    prepopulated_fields = {
-        'slug': ['name'],
-    }
-
-
-@admin.register(VLAN)
-class VLANAdmin(admin.ModelAdmin):
-    list_display = ['site', 'vid', 'name', 'tenant', 'status', 'role']
-    list_filter = ['site', 'tenant', 'status', 'role']
-    search_fields = ['vid', 'name']
-
-    def get_queryset(self, request):
-        qs = super(VLANAdmin, self).get_queryset(request)
-        return qs.select_related('site', 'tenant', 'role')

+ 158 - 66
netbox/ipam/api/serializers.py

@@ -1,36 +1,41 @@
 from rest_framework import serializers
+from rest_framework.validators import UniqueTogetherValidator
 
-from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer
-from extras.api.serializers import CustomFieldSerializer
-from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
-from tenancy.api.serializers import TenantNestedSerializer
+from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
+from extras.api.customfields import CustomFieldModelSerializer
+from ipam.models import (
+    Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
+    Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
+)
+from tenancy.api.serializers import NestedTenantSerializer
+from utilities.api import ChoiceFieldSerializer
 
 
 #
 # VRFs
 #
 
-class VRFSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    tenant = TenantNestedSerializer()
+class VRFSerializer(CustomFieldModelSerializer):
+    tenant = NestedTenantSerializer()
 
     class Meta:
         model = VRF
         fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
 
 
-class VRFNestedSerializer(VRFSerializer):
+class NestedVRFSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
 
-    class Meta(VRFSerializer.Meta):
-        fields = ['id', 'name', 'rd']
+    class Meta:
+        model = VRF
+        fields = ['id', 'url', 'name', 'rd']
 
 
-class VRFTenantSerializer(VRFSerializer):
-    """
-    Include tenant serializer. Useful for determining tenant inheritance for Prefixes and IPAddresses.
-    """
+class WritableVRFSerializer(CustomFieldModelSerializer):
 
-    class Meta(VRFSerializer.Meta):
-        fields = ['id', 'name', 'rd', 'tenant']
+    class Meta:
+        model = VRF
+        fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
 
 
 #
@@ -44,10 +49,12 @@ class RoleSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'weight']
 
 
-class RoleNestedSerializer(RoleSerializer):
+class NestedRoleSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
 
-    class Meta(RoleSerializer.Meta):
-        fields = ['id', 'name', 'slug']
+    class Meta:
+        model = Role
+        fields = ['id', 'url', 'name', 'slug']
 
 
 #
@@ -61,28 +68,39 @@ class RIRSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'is_private']
 
 
-class RIRNestedSerializer(RIRSerializer):
+class NestedRIRSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
 
-    class Meta(RIRSerializer.Meta):
-        fields = ['id', 'name', 'slug']
+    class Meta:
+        model = RIR
+        fields = ['id', 'url', 'name', 'slug']
 
 
 #
 # Aggregates
 #
 
-class AggregateSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    rir = RIRNestedSerializer()
+class AggregateSerializer(CustomFieldModelSerializer):
+    rir = NestedRIRSerializer()
 
     class Meta:
         model = Aggregate
         fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
 
 
-class AggregateNestedSerializer(AggregateSerializer):
+class NestedAggregateSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
 
     class Meta(AggregateSerializer.Meta):
-        fields = ['id', 'family', 'prefix']
+        model = Aggregate
+        fields = ['id', 'url', 'family', 'prefix']
+
+
+class WritableAggregateSerializer(CustomFieldModelSerializer):
+
+    class Meta:
+        model = Aggregate
+        fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
 
 
 #
@@ -90,86 +108,158 @@ class AggregateNestedSerializer(AggregateSerializer):
 #
 
 class VLANGroupSerializer(serializers.ModelSerializer):
-    site = SiteNestedSerializer()
+    site = NestedSiteSerializer()
 
     class Meta:
         model = VLANGroup
         fields = ['id', 'name', 'slug', 'site']
 
 
-class VLANGroupNestedSerializer(VLANGroupSerializer):
+class NestedVLANGroupSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
+
+    class Meta:
+        model = VLANGroup
+        fields = ['id', 'url', 'name', 'slug']
 
-    class Meta(VLANGroupSerializer.Meta):
-        fields = ['id', 'name', 'slug']
+
+class WritableVLANGroupSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = VLANGroup
+        fields = ['id', 'name', 'slug', 'site']
+        validators = []
+
+    def validate(self, data):
+
+        # Validate uniqueness of name and slug if a site has been assigned.
+        if data.get('site', None):
+            for field in ['name', 'slug']:
+                validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('site', field))
+                validator.set_context(self)
+                validator(data)
+
+        return data
 
 
 #
 # VLANs
 #
 
-class VLANSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    site = SiteNestedSerializer()
-    group = VLANGroupNestedSerializer()
-    tenant = TenantNestedSerializer()
-    role = RoleNestedSerializer()
+class VLANSerializer(CustomFieldModelSerializer):
+    site = NestedSiteSerializer()
+    group = NestedVLANGroupSerializer()
+    tenant = NestedTenantSerializer()
+    status = ChoiceFieldSerializer(choices=VLAN_STATUS_CHOICES)
+    role = NestedRoleSerializer()
 
     class Meta:
         model = VLAN
-        fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
-                  'custom_fields']
+        fields = [
+            'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
+            'custom_fields',
+        ]
 
 
-class VLANNestedSerializer(VLANSerializer):
+class NestedVLANSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
 
-    class Meta(VLANSerializer.Meta):
-        fields = ['id', 'vid', 'name', 'display_name']
+    class Meta:
+        model = VLAN
+        fields = ['id', 'url', 'vid', 'name', 'display_name']
+
+
+class WritableVLANSerializer(CustomFieldModelSerializer):
+
+    class Meta:
+        model = VLAN
+        fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
+        validators = []
+
+    def validate(self, data):
+
+        # Validate uniqueness of vid and name if a group has been assigned.
+        if data.get('group', None):
+            for field in ['vid', 'name']:
+                validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field))
+                validator.set_context(self)
+                validator(data)
+
+        return data
 
 
 #
 # Prefixes
 #
 
-class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    site = SiteNestedSerializer()
-    vrf = VRFTenantSerializer()
-    tenant = TenantNestedSerializer()
-    vlan = VLANNestedSerializer()
-    role = RoleNestedSerializer()
+class PrefixSerializer(CustomFieldModelSerializer):
+    site = NestedSiteSerializer()
+    vrf = NestedVRFSerializer()
+    tenant = NestedTenantSerializer()
+    vlan = NestedVLANSerializer()
+    status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES)
+    role = NestedRoleSerializer()
+
+    class Meta:
+        model = Prefix
+        fields = [
+            'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
+            'custom_fields',
+        ]
+
+
+class NestedPrefixSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
 
     class Meta:
         model = Prefix
-        fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
-                  'custom_fields']
+        fields = ['id', 'url', 'family', 'prefix']
 
 
-class PrefixNestedSerializer(PrefixSerializer):
+class WritablePrefixSerializer(CustomFieldModelSerializer):
 
-    class Meta(PrefixSerializer.Meta):
-        fields = ['id', 'family', 'prefix']
+    class Meta:
+        model = Prefix
+        fields = [
+            'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
+            'custom_fields',
+        ]
 
 
 #
 # IP addresses
 #
 
-class IPAddressSerializer(CustomFieldSerializer, serializers.ModelSerializer):
-    vrf = VRFTenantSerializer()
-    tenant = TenantNestedSerializer()
-    interface = InterfaceNestedSerializer()
+class IPAddressSerializer(CustomFieldModelSerializer):
+    vrf = NestedVRFSerializer()
+    tenant = NestedTenantSerializer()
+    status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
+    interface = InterfaceSerializer()
 
     class Meta:
         model = IPAddress
-        fields = ['id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside',
-                  'nat_outside', 'custom_fields']
+        fields = [
+            'id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside',
+            'nat_outside', 'custom_fields',
+        ]
 
 
-class IPAddressNestedSerializer(IPAddressSerializer):
+class NestedIPAddressSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
 
-    class Meta(IPAddressSerializer.Meta):
-        fields = ['id', 'family', 'address']
+    class Meta:
+        model = IPAddress
+        fields = ['id', 'url', 'family', 'address']
+
+IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
+IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
 
-IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer()
-IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer()
+
+class WritableIPAddressSerializer(CustomFieldModelSerializer):
+
+    class Meta:
+        model = IPAddress
+        fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', 'custom_fields']
 
 
 #
@@ -177,15 +267,17 @@ IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer(
 #
 
 class ServiceSerializer(serializers.ModelSerializer):
-    device = DeviceNestedSerializer()
-    ipaddresses = IPAddressNestedSerializer(many=True)
+    device = NestedDeviceSerializer()
+    protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
+    ipaddresses = NestedIPAddressSerializer(many=True)
 
     class Meta:
         model = Service
         fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
 
 
-class ServiceNestedSerializer(ServiceSerializer):
+class WritableServiceSerializer(serializers.ModelSerializer):
 
-    class Meta(ServiceSerializer.Meta):
-        fields = ['id', 'name', 'port', 'protocol']
+    class Meta:
+        model = Service
+        fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']

+ 28 - 31
netbox/ipam/api/urls.py

@@ -1,44 +1,41 @@
-from django.conf.urls import url
+from rest_framework import routers
 
-from .views import *
+from . import views
 
 
-urlpatterns = [
+class IPAMRootView(routers.APIRootView):
+    """
+    IPAM API root view
+    """
+    def get_view_name(self):
+        return 'IPAM'
 
-    # VRFs
-    url(r'^vrfs/$', VRFListView.as_view(), name='vrf_list'),
-    url(r'^vrfs/(?P<pk>\d+)/$', VRFDetailView.as_view(), name='vrf_detail'),
 
-    # Roles
-    url(r'^roles/$', RoleListView.as_view(), name='role_list'),
-    url(r'^roles/(?P<pk>\d+)/$', RoleDetailView.as_view(), name='role_detail'),
+router = routers.DefaultRouter()
+router.APIRootView = IPAMRootView
 
-    # RIRs
-    url(r'^rirs/$', RIRListView.as_view(), name='rir_list'),
-    url(r'^rirs/(?P<pk>\d+)/$', RIRDetailView.as_view(), name='rir_detail'),
+# VRFs
+router.register(r'vrfs', views.VRFViewSet)
 
-    # Aggregates
-    url(r'^aggregates/$', AggregateListView.as_view(), name='aggregate_list'),
-    url(r'^aggregates/(?P<pk>\d+)/$', AggregateDetailView.as_view(), name='aggregate_detail'),
+# RIRs
+router.register(r'rirs', views.RIRViewSet)
 
-    # Prefixes
-    url(r'^prefixes/$', PrefixListView.as_view(), name='prefix_list'),
-    url(r'^prefixes/(?P<pk>\d+)/$', PrefixDetailView.as_view(), name='prefix_detail'),
+# Aggregates
+router.register(r'aggregates', views.AggregateViewSet)
 
-    # IP addresses
-    url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
-    url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
+# Prefixes
+router.register(r'roles', views.RoleViewSet)
+router.register(r'prefixes', views.PrefixViewSet)
 
-    # VLAN groups
-    url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'),
-    url(r'^vlan-groups/(?P<pk>\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'),
+# IP addresses
+router.register(r'ip-addresses', views.IPAddressViewSet)
 
-    # VLANs
-    url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
-    url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
+# VLANs
+router.register(r'vlan-groups', views.VLANGroupViewSet)
+router.register(r'vlans', views.VLANViewSet)
 
-    # Services
-    url(r'^services/$', ServiceListView.as_view(), name='service_list'),
-    url(r'^services/(?P<pk>\d+)/$', ServiceDetailView.as_view(), name='service_detail'),
+# Services
+router.register(r'services', views.ServiceViewSet)
 
-]
+app_name = 'ipam-api'
+urlpatterns = router.urls

+ 26 - 123
netbox/ipam/api/views.py

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

+ 2 - 2
netbox/ipam/forms.py

@@ -341,7 +341,7 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
     )
     livesearch = forms.CharField(
         required=False, label='IP Address', widget=Livesearch(
-            query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address'
+            query_key='q', query_url='ipam-api:ipaddress-list', field_to_update='nat_inside', obj_label='address'
         )
     )
     primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
@@ -350,7 +350,7 @@ class IPAddressForm(BootstrapMixin, ReturnURLForm, CustomFieldForm):
         model = IPAddress
         fields = ['address', 'vrf', 'tenant', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside']
         widgets = {
-            'interface': APISelect(api_url='/api/dcim/devices/{{interface_device}}/interfaces/'),
+            'interface': APISelect(api_url='/api/dcim/devices/interfaces/?device_id={{interface_device}}'),
             'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
         }
 

+ 1 - 1
netbox/ipam/models.py

@@ -3,10 +3,10 @@ from netaddr import IPNetwork, cidr_merge
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
-from django.core.urlresolvers import reverse
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models.expressions import RawSQL
+from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 
 from dcim.models import Interface

+ 86 - 36
netbox/ipam/tables.py

@@ -1,7 +1,7 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
-from utilities.tables import BaseTable, ToggleColumn
+from utilities.tables import BaseTable, SearchTable, ToggleColumn
 
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
@@ -133,16 +133,25 @@ TENANT_LINK = """
 
 class VRFTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
+    name = tables.LinkColumn()
     rd = tables.Column(verbose_name='RD')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    description = tables.Column(verbose_name='Description')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
 
     class Meta(BaseTable.Meta):
         model = VRF
         fields = ('pk', 'name', 'rd', 'tenant', 'description')
 
 
+class VRFSearchTable(SearchTable):
+    name = tables.LinkColumn()
+    rd = tables.Column(verbose_name='RD')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(SearchTable.Meta):
+        model = VRF
+        fields = ('name', 'rd', 'tenant', 'description')
+
+
 #
 # RIRs
 #
@@ -177,18 +186,25 @@ class RIRTable(BaseTable):
 
 class AggregateTable(BaseTable):
     pk = ToggleColumn()
-    prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
-    rir = tables.Column(verbose_name='RIR')
+    prefix = tables.LinkColumn(verbose_name='Aggregate')
     child_count = tables.Column(verbose_name='Prefixes')
     get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
     date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
-    description = tables.Column(verbose_name='Description')
 
     class Meta(BaseTable.Meta):
         model = Aggregate
         fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
 
 
+class AggregateSearchTable(SearchTable):
+    prefix = tables.LinkColumn(verbose_name='Aggregate')
+    date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
+
+    class Meta(SearchTable.Meta):
+        model = Aggregate
+        fields = ('prefix', 'rir', 'date_added', 'description')
+
+
 #
 # Roles
 #
@@ -212,14 +228,13 @@ class RoleTable(BaseTable):
 
 class PrefixTable(BaseTable):
     pk = ToggleColumn()
-    status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
-    prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix', attrs={'th': {'style': 'padding-left: 17px'}})
+    prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
+    status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
-    tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
+    tenant = tables.TemplateColumn(TENANT_LINK)
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
-    role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
-    description = tables.Column(verbose_name='Description')
+    role = tables.TemplateColumn(PREFIX_ROLE_LINK)
 
     class Meta(BaseTable.Meta):
         model = Prefix
@@ -230,12 +245,11 @@ class PrefixTable(BaseTable):
 
 
 class PrefixBriefTable(BaseTable):
-    prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix')
-    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
-    status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
-    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
-    role = tables.Column(verbose_name='Role')
+    prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF)
+    vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    status = tables.TemplateColumn(STATUS_LABEL)
+    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')])
 
     class Meta(BaseTable.Meta):
         model = Prefix
@@ -243,6 +257,20 @@ class PrefixBriefTable(BaseTable):
         orderable = False
 
 
+class PrefixSearchTable(SearchTable):
+    prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
+    status = tables.TemplateColumn(STATUS_LABEL)
+    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
+    tenant = tables.TemplateColumn(TENANT_LINK)
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
+    role = tables.TemplateColumn(PREFIX_ROLE_LINK)
+
+    class Meta(SearchTable.Meta):
+        model = Prefix
+        fields = ('prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
+
+
 #
 # IPAddresses
 #
@@ -250,13 +278,11 @@ class PrefixBriefTable(BaseTable):
 class IPAddressTable(BaseTable):
     pk = ToggleColumn()
     address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
-    status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
+    status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
-    tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
-    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
-                               verbose_name='Device')
-    interface = tables.Column(orderable=False, verbose_name='Interface')
-    description = tables.Column(verbose_name='Description')
+    tenant = tables.TemplateColumn(TENANT_LINK)
+    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
+    interface = tables.Column(orderable=False)
 
     class Meta(BaseTable.Meta):
         model = IPAddress
@@ -268,17 +294,30 @@ class IPAddressTable(BaseTable):
 
 class IPAddressBriefTable(BaseTable):
     address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
-    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
-                               verbose_name='Device')
-    interface = tables.Column(orderable=False, verbose_name='Interface')
-    nat_inside = tables.LinkColumn('ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False,
-                                   verbose_name='NAT (Inside)')
+    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
+    interface = tables.Column(orderable=False)
+    nat_inside = tables.LinkColumn(
+        'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
+    )
 
     class Meta(BaseTable.Meta):
         model = IPAddress
         fields = ('address', 'device', 'interface', 'nat_inside')
 
 
+class IPAddressSearchTable(SearchTable):
+    address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
+    status = tables.TemplateColumn(STATUS_LABEL)
+    vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
+    tenant = tables.TemplateColumn(TENANT_LINK)
+    device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
+    interface = tables.Column(orderable=False)
+
+    class Meta(SearchTable.Meta):
+        model = IPAddress
+        fields = ('address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
+
+
 #
 # VLAN groups
 #
@@ -304,15 +343,26 @@ class VLANGroupTable(BaseTable):
 class VLANTable(BaseTable):
     pk = ToggleColumn()
     vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
     group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
-    name = tables.Column(verbose_name='Name')
     prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
-    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
-    role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
-    description = tables.Column(verbose_name='Description')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    status = tables.TemplateColumn(STATUS_LABEL)
+    role = tables.TemplateColumn(VLAN_ROLE_LINK)
 
     class Meta(BaseTable.Meta):
         model = VLAN
         fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
+
+
+class VLANSearchTable(SearchTable):
+    vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
+    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+    status = tables.TemplateColumn(STATUS_LABEL)
+    role = tables.TemplateColumn(VLAN_ROLE_LINK)
+
+    class Meta(SearchTable.Meta):
+        model = VLAN
+        fields = ('vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')

+ 660 - 0
netbox/ipam/tests/test_api.py

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

+ 1 - 0
netbox/ipam/urls.py

@@ -3,6 +3,7 @@ from django.conf.urls import url
 from . import views
 
 
+app_name = 'ipam'
 urlpatterns = [
 
     # VRFs

+ 1 - 1
netbox/ipam/views.py

@@ -5,9 +5,9 @@ from django.conf import settings
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib import messages
-from django.core.urlresolvers import reverse
 from django.db.models import Count, Q
 from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
 
 from dcim.models import Device
 from utilities.forms import ConfirmationForm

+ 2 - 0
netbox/media/image-attachments/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 29 - 18
netbox/netbox/configuration.example.py

@@ -38,6 +38,26 @@ ADMINS = [
     # ['John Doe', 'jdoe@example.com'],
 ]
 
+# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both
+# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
+BANNER_TOP = ''
+BANNER_BOTTOM = ''
+
+# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
+# BASE_PATH = 'netbox/'
+BASE_PATH = ''
+
+# API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be
+# allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or
+# CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers
+CORS_ORIGIN_ALLOW_ALL = False
+CORS_ORIGIN_WHITELIST = [
+    # 'hostname.example.com',
+]
+CORS_ORIGIN_REGEX_WHITELIST = [
+    # r'^(https?://)?(\w+\.)?example\.com$',
+]
+
 # Email settings
 EMAIL = {
     'SERVER': 'localhost',
@@ -48,24 +68,28 @@ EMAIL = {
     'FROM_EMAIL': '',
 }
 
+# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
+# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
+ENFORCE_GLOBAL_UNIQUE = False
+
 # Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
 # are permitted to access most data in NetBox (excluding secrets) but not make any changes.
 LOGIN_REQUIRED = False
 
-# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
-# BASE_PATH = 'netbox/'
-BASE_PATH = ''
-
 # Setting this to True will display a "maintenance mode" banner at the top of every page.
 MAINTENANCE_MODE = False
 
-# Credentials that NetBox will use to access live devices.
+# Credentials that NetBox will use to access live devices (future use).
 NETBOX_USERNAME = ''
 NETBOX_PASSWORD = ''
 
 # Determine how many objects to display per page within a list. (Default: 50)
 PAGINATE_COUNT = 50
 
+# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
+# prefer IPv4 instead.
+PREFER_IPV4 = False
+
 # Time zone (default: UTC)
 TIME_ZONE = 'UTC'
 
@@ -77,16 +101,3 @@ TIME_FORMAT = 'g:i a'
 SHORT_TIME_FORMAT = 'H:i:s'
 DATETIME_FORMAT = 'N j, Y g:i a'
 SHORT_DATETIME_FORMAT = 'Y-m-d H:i'
-
-# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both
-# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
-BANNER_TOP = ''
-BANNER_BOTTOM = ''
-
-# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
-# prefer IPv4 instead.
-PREFER_IPV4 = False
-
-# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
-# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
-ENFORCE_GLOBAL_UNIQUE = False

+ 40 - 0
netbox/netbox/forms.py

@@ -0,0 +1,40 @@
+from django import forms
+
+from utilities.forms import BootstrapMixin
+
+
+OBJ_TYPE_CHOICES = (
+    ('', 'All Objects'),
+    ('Circuits', (
+        ('provider', 'Providers'),
+        ('circuit', 'Circuits'),
+    )),
+    ('DCIM', (
+        ('site', 'Sites'),
+        ('rack', 'Racks'),
+        ('devicetype', 'Device types'),
+        ('device', 'Devices'),
+    )),
+    ('IPAM', (
+        ('vrf', 'VRFs'),
+        ('aggregate', 'Aggregates'),
+        ('prefix', 'Prefixes'),
+        ('ipaddress', 'IP addresses'),
+        ('vlan', 'VLANs'),
+    )),
+    ('Secrets', (
+        ('secret', 'Secrets'),
+    )),
+    ('Tenancy', (
+        ('tenant', 'Tenants'),
+    )),
+)
+
+
+class SearchForm(BootstrapMixin, forms.Form):
+    q = forms.CharField(
+        label='Query', widget=forms.TextInput(attrs={'style': 'width: 350px'})
+    )
+    obj_type = forms.ChoiceField(
+        choices=OBJ_TYPE_CHOICES, required=False, label='Type'
+    )

+ 48 - 13
netbox/netbox/settings.py

@@ -8,19 +8,22 @@ from django.core.exceptions import ImproperlyConfigured
 try:
     from netbox import configuration
 except ImportError:
-    raise ImproperlyConfigured("Configuration file is not present. Please define netbox/netbox/configuration.py per "
-                               "the documentation.")
+    raise ImproperlyConfigured(
+        "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
+    )
 
 
-VERSION = '1.9.7-dev'
+VERSION = '2.0.0-dev'
 
 # Import local configuration
+ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
     try:
         globals()[setting] = getattr(configuration, setting)
     except AttributeError:
-        raise ImproperlyConfigured("Mandatory setting {} is missing from configuration.py. Please define it per the "
-                                   "documentation.".format(setting))
+        raise ImproperlyConfigured(
+            "Mandatory setting {} is missing from configuration.py.".format(setting)
+        )
 
 # Default configurations
 ADMINS = getattr(configuration, 'ADMINS', [])
@@ -45,6 +48,9 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
 BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
 ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
+CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
+CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
+CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
 
 # Attempt to import LDAP configuration if it has been defined
@@ -73,8 +79,10 @@ if LDAP_CONFIGURED:
         logger.addHandler(logging.StreamHandler())
         logger.setLevel(logging.DEBUG)
     except ImportError:
-        raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. "
-                                   "You can remove netbox/ldap_config.py to disable LDAP.")
+        raise ImproperlyConfigured(
+            "LDAP authentication has been configured, but django-auth-ldap is not installed. You can remove "
+            "netbox/ldap_config.py to disable LDAP."
+        )
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
@@ -102,6 +110,7 @@ INSTALLED_APPS = (
     'django.contrib.messages',
     'django.contrib.staticfiles',
     'django.contrib.humanize',
+    'corsheaders',
     'debug_toolbar',
     'django_tables2',
     'mptt',
@@ -120,6 +129,7 @@ INSTALLED_APPS = (
 # Middleware
 MIDDLEWARE = (
     'debug_toolbar.middleware.DebugToolbarMiddleware',
+    'corsheaders.middleware.CorsMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',
@@ -129,6 +139,7 @@ MIDDLEWARE = (
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'utilities.middleware.LoginRequiredMiddleware',
+    'utilities.middleware.APIVersionMiddleware',
 )
 
 ROOT_URLCONF = 'netbox.urls'
@@ -142,6 +153,7 @@ TEMPLATES = [
             'context_processors': [
                 'django.template.context_processors.debug',
                 'django.template.context_processors.request',
+                'django.template.context_processors.media',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
                 'utilities.context_processors.settings',
@@ -156,19 +168,21 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
 USE_X_FORWARDED_HOST = True
 
 # Internationalization
-# https://docs.djangoproject.com/en/1.8/topics/i18n/
 LANGUAGE_CODE = 'en-us'
 USE_I18N = True
 USE_TZ = True
 
 # Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/1.8/howto/static-files/
 STATIC_ROOT = BASE_DIR + '/static/'
 STATIC_URL = '/{}static/'.format(BASE_PATH)
 STATICFILES_DIRS = (
     os.path.join(BASE_DIR, "project-static"),
 )
 
+# Media
+MEDIA_URL = '/media/'
+MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+
 # Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
 DATA_UPLOAD_MAX_NUMBER_FIELDS = None
 
@@ -183,14 +197,35 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
 # Secrets
 SECRETS_MIN_PUBKEY_SIZE = 2048
 
-# Django REST framework
+# Django REST framework (API)
+REST_FRAMEWORK_VERSION = VERSION[0:3]  # Use major.minor as API version
 REST_FRAMEWORK = {
-    'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',)
+    'DEFAULT_AUTHENTICATION_CLASSES': (
+        'rest_framework.authentication.SessionAuthentication',
+        'utilities.api.TokenAuthentication',
+    ),
+    'DEFAULT_FILTER_BACKENDS': (
+        'rest_framework.filters.DjangoFilterBackend',
+    ),
+    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
+    'DEFAULT_PERMISSION_CLASSES': (
+        'utilities.api.TokenPermissions',
+    ),
+    'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
+    'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
+    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
+    'PAGE_SIZE': PAGINATE_COUNT,
 }
-if LOGIN_REQUIRED:
-    REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'] = ('rest_framework.permissions.IsAuthenticated',)
 
 # Django debug toolbar
+# Disable the templates panel by default due to a performance issue in Django 1.11; see
+# https://github.com/jazzband/django-debug-toolbar/issues/910
+DEBUG_TOOLBAR_CONFIG = {
+    'DISABLE_PANELS': [
+        'debug_toolbar.panels.redirects.RedirectsPanel',
+        'debug_toolbar.panels.templates.TemplatesPanel',
+    ],
+}
 INTERNAL_IPS = (
     '127.0.0.1',
     '::1',

+ 22 - 15
netbox/netbox/urls.py

@@ -3,8 +3,9 @@ from rest_framework_swagger.views import get_swagger_view
 from django.conf import settings
 from django.conf.urls import include, url
 from django.contrib import admin
+from django.views.static import serve
 
-from netbox.views import home, handle_500, trigger_500
+from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
 from users.views import login, logout
 
 
@@ -13,35 +14,41 @@ swagger_view = get_swagger_view(title='NetBox API')
 
 _patterns = [
 
-    # Default page
+    # Base views
     url(r'^$', home, name='home'),
+    url(r'^search/$', SearchView.as_view(), name='search'),
 
     # Login/logout
     url(r'^login/$', login, name='login'),
     url(r'^logout/$', logout, name='logout'),
 
     # Apps
-    url(r'^circuits/', include('circuits.urls', namespace='circuits')),
-    url(r'^dcim/', include('dcim.urls', namespace='dcim')),
-    url(r'^ipam/', include('ipam.urls', namespace='ipam')),
-    url(r'^secrets/', include('secrets.urls', namespace='secrets')),
-    url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
-    url(r'^user/', include('users.urls', namespace='user')),
+    url(r'^circuits/', include('circuits.urls')),
+    url(r'^dcim/', include('dcim.urls')),
+    url(r'^extras/', include('extras.urls')),
+    url(r'^ipam/', include('ipam.urls')),
+    url(r'^secrets/', include('secrets.urls')),
+    url(r'^tenancy/', include('tenancy.urls')),
+    url(r'^user/', include('users.urls')),
 
     # 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/ipam/', include('ipam.api.urls', namespace='ipam-api')),
-    url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
-    url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
+    url(r'^api/$', APIRootView.as_view(), name='api-root'),
+    url(r'^api/circuits/', include('circuits.api.urls')),
+    url(r'^api/dcim/', include('dcim.api.urls')),
+    url(r'^api/extras/', include('extras.api.urls')),
+    url(r'^api/ipam/', include('ipam.api.urls')),
+    url(r'^api/secrets/', include('secrets.api.urls')),
+    url(r'^api/tenancy/', include('tenancy.api.urls')),
     url(r'^api/docs/', swagger_view, name='api_docs'),
-    url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
+
+    # Serving static media in Django to pipe it through LoginRequiredMiddleware
+    url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
 
     # Error testing
     url(r'^500/$', trigger_500),
 
     # Admin
-    url(r'^admin/', include(admin.site.urls)),
+    url(r'^admin/', admin.site.urls),
 
 ]
 

+ 177 - 5
netbox/netbox/views.py

@@ -1,13 +1,117 @@
 import sys
 
-from django.shortcuts import render
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.reverse import reverse
 
-from circuits.models import Provider, Circuit
-from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
-from extras.models import UserAction
-from ipam.models import Aggregate, Prefix, IPAddress, VLAN, VRF
+from django.shortcuts import render
+from django.views.generic import View
+
+from circuits.filters import CircuitFilter, ProviderFilter
+from circuits.models import Circuit, Provider
+from circuits.tables import CircuitSearchTable, ProviderSearchTable
+from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
+from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
+from dcim.tables import DeviceSearchTable, DeviceTypeSearchTable, RackSearchTable, SiteSearchTable
+from extras.models import TopologyMap, UserAction
+from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
+from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
+from ipam.tables import AggregateSearchTable, IPAddressSearchTable, PrefixSearchTable, VLANSearchTable, VRFSearchTable
+from secrets.filters import SecretFilter
 from secrets.models import Secret
+from secrets.tables import SecretSearchTable
+from tenancy.filters import TenantFilter
 from tenancy.models import Tenant
+from tenancy.tables import TenantSearchTable
+from .forms import SearchForm
+
+
+SEARCH_MAX_RESULTS = 15
+SEARCH_TYPES = {
+    # Circuits
+    'provider': {
+        'queryset': Provider.objects.all(),
+        'filter': ProviderFilter,
+        'table': ProviderSearchTable,
+        'url': 'circuits:provider_list',
+    },
+    'circuit': {
+        'queryset': Circuit.objects.select_related('type', 'provider', 'tenant'),
+        'filter': CircuitFilter,
+        'table': CircuitSearchTable,
+        'url': 'circuits:circuit_list',
+    },
+    # DCIM
+    'site': {
+        'queryset': Site.objects.select_related('region', 'tenant'),
+        'filter': SiteFilter,
+        'table': SiteSearchTable,
+        'url': 'dcim:site_list',
+    },
+    'rack': {
+        'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
+        'filter': RackFilter,
+        'table': RackSearchTable,
+        'url': 'dcim:rack_list',
+    },
+    'devicetype': {
+        'queryset': DeviceType.objects.select_related('manufacturer'),
+        'filter': DeviceTypeFilter,
+        'table': DeviceTypeSearchTable,
+        'url': 'dcim:devicetype_list',
+    },
+    'device': {
+        'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'),
+        'filter': DeviceFilter,
+        'table': DeviceSearchTable,
+        'url': 'dcim:device_list',
+    },
+    # IPAM
+    'vrf': {
+        'queryset': VRF.objects.select_related('tenant'),
+        'filter': VRFFilter,
+        'table': VRFSearchTable,
+        'url': 'ipam:vrf_list',
+    },
+    'aggregate': {
+        'queryset': Aggregate.objects.select_related('rir'),
+        'filter': AggregateFilter,
+        'table': AggregateSearchTable,
+        'url': 'ipam:aggregate_list',
+    },
+    'prefix': {
+        'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
+        'filter': PrefixFilter,
+        'table': PrefixSearchTable,
+        'url': 'ipam:prefix_list',
+    },
+    'ipaddress': {
+        'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
+        'filter': IPAddressFilter,
+        'table': IPAddressSearchTable,
+        'url': 'ipam:ipaddress_list',
+    },
+    'vlan': {
+        'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
+        'filter': VLANFilter,
+        'table': VLANSearchTable,
+        'url': 'ipam:vlan_list',
+    },
+    # Secrets
+    'secret': {
+        'queryset': Secret.objects.select_related('role', 'device'),
+        'filter': SecretFilter,
+        'table': SecretSearchTable,
+        'url': 'secrets:secret_list',
+    },
+    # Tenancy
+    'tenant': {
+        'queryset': Tenant.objects.select_related('group'),
+        'filter': TenantFilter,
+        'table': TenantSearchTable,
+        'url': 'tenancy:tenant_list',
+    },
+}
 
 
 def home(request):
@@ -42,11 +146,79 @@ def home(request):
     }
 
     return render(request, 'home.html', {
+        'search_form': SearchForm(),
         'stats': stats,
+        'topology_maps': TopologyMap.objects.filter(site__isnull=True),
         'recent_activity': UserAction.objects.select_related('user')[:50]
     })
 
 
+class SearchView(View):
+
+    def get(self, request):
+
+        # No query
+        if 'q' not in request.GET:
+            return render(request, 'search.html', {
+                'form': SearchForm(),
+            })
+
+        form = SearchForm(request.GET)
+        results = []
+
+        if form.is_valid():
+
+            # Searching for a single type of object
+            if form.cleaned_data['obj_type']:
+                obj_types = [form.cleaned_data['obj_type']]
+            # Searching all object types
+            else:
+                obj_types = SEARCH_TYPES.keys()
+
+            for obj_type in obj_types:
+
+                queryset = SEARCH_TYPES[obj_type]['queryset']
+                filter_cls = SEARCH_TYPES[obj_type]['filter']
+                table = SEARCH_TYPES[obj_type]['table']
+                url = SEARCH_TYPES[obj_type]['url']
+
+                # Construct the results table for this object type
+                filtered_queryset = filter_cls({'q': form.cleaned_data['q']}, queryset=queryset).qs
+                table = table(filtered_queryset)
+                table.paginate(per_page=SEARCH_MAX_RESULTS)
+
+                if table.page:
+                    results.append({
+                        'name': queryset.model._meta.verbose_name_plural,
+                        'table': table,
+                        'url': '{}?q={}'.format(reverse(url), form.cleaned_data['q'])
+                    })
+
+        return render(request, 'search.html', {
+            'form': form,
+            'results': results,
+        })
+
+
+class APIRootView(APIView):
+    _ignore_model_permissions = True
+    exclude_from_schema = True
+
+    def get_view_name(self):
+        return u"API Root"
+
+    def get(self, request, format=None):
+
+        return Response({
+            'circuits': reverse('circuits-api:api-root', request=request, format=format),
+            'dcim': reverse('dcim-api:api-root', request=request, format=format),
+            'extras': reverse('extras-api:api-root', request=request, format=format),
+            'ipam': reverse('ipam-api:api-root', request=request, format=format),
+            'secrets': reverse('secrets-api:api-root', request=request, format=format),
+            'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
+        })
+
+
 def handle_500(request):
     """
     Custom server error handler

File diff suppressed because it is too large
+ 0 - 1
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css.map


File diff suppressed because it is too large
+ 0 - 1
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css.map


File diff suppressed because it is too large
+ 0 - 6
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.min.css


File diff suppressed because it is too large
+ 0 - 1
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.min.css.map


File diff suppressed because it is too large
+ 0 - 7
netbox/project-static/bootstrap-3.3.6-dist/js/bootstrap.min.js


+ 2 - 2
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.css

@@ -1,6 +1,6 @@
 /*!
- * Bootstrap v3.3.6 (http://getbootstrap.com)
- * Copyright 2011-2015 Twitter, Inc.
+ * Bootstrap v3.3.7 (http://getbootstrap.com)
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  */
 .btn-default,

File diff suppressed because it is too large
+ 1 - 1
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.css.map


File diff suppressed because it is too large
+ 2 - 2
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css


File diff suppressed because it is too large
+ 1 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap-theme.min.css.map


+ 2 - 5
netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css

@@ -1,6 +1,6 @@
 /*!
- * Bootstrap v3.3.6 (http://getbootstrap.com)
- * Copyright 2011-2015 Twitter, Inc.
+ * Bootstrap v3.3.7 (http://getbootstrap.com)
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  */
 /*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
@@ -1106,7 +1106,6 @@ a:focus {
   text-decoration: underline;
 }
 a:focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
 }
@@ -2537,7 +2536,6 @@ select[size] {
 input[type="file"]:focus,
 input[type="radio"]:focus,
 input[type="checkbox"]:focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
 }
@@ -3029,7 +3027,6 @@ select[multiple].input-lg {
 .btn.focus,
 .btn:active.focus,
 .btn.active.focus {
-  outline: thin dotted;
   outline: 5px auto -webkit-focus-ring-color;
   outline-offset: -2px;
 }

File diff suppressed because it is too large
+ 1 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.css.map


File diff suppressed because it is too large
+ 6 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css


File diff suppressed because it is too large
+ 1 - 0
netbox/project-static/bootstrap-3.3.7-dist/css/bootstrap.min.css.map


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.eot → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.eot


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.svg → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.svg


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.ttf → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.ttf


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.woff → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff


netbox/project-static/bootstrap-3.3.6-dist/fonts/glyphicons-halflings-regular.woff2 → netbox/project-static/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff2


+ 64 - 50
netbox/project-static/bootstrap-3.3.6-dist/js/bootstrap.js

@@ -1,6 +1,6 @@
 /*!
- * Bootstrap v3.3.6 (http://getbootstrap.com)
- * Copyright 2011-2015 Twitter, Inc.
+ * Bootstrap v3.3.7 (http://getbootstrap.com)
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under the MIT license
  */
 
@@ -11,16 +11,16 @@ if (typeof jQuery === 'undefined') {
 +function ($) {
   'use strict';
   var version = $.fn.jquery.split(' ')[0].split('.')
-  if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) {
-    throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3')
+  if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)) {
+    throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4')
   }
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: transition.js v3.3.6
+ * Bootstrap: transition.js v3.3.7
  * http://getbootstrap.com/javascript/#transitions
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -77,10 +77,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: alert.js v3.3.6
+ * Bootstrap: alert.js v3.3.7
  * http://getbootstrap.com/javascript/#alerts
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -96,7 +96,7 @@ if (typeof jQuery === 'undefined') {
     $(el).on('click', dismiss, this.close)
   }
 
-  Alert.VERSION = '3.3.6'
+  Alert.VERSION = '3.3.7'
 
   Alert.TRANSITION_DURATION = 150
 
@@ -109,7 +109,7 @@ if (typeof jQuery === 'undefined') {
       selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
     }
 
-    var $parent = $(selector)
+    var $parent = $(selector === '#' ? [] : selector)
 
     if (e) e.preventDefault()
 
@@ -172,10 +172,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: button.js v3.3.6
+ * Bootstrap: button.js v3.3.7
  * http://getbootstrap.com/javascript/#buttons
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -192,7 +192,7 @@ if (typeof jQuery === 'undefined') {
     this.isLoading = false
   }
 
-  Button.VERSION  = '3.3.6'
+  Button.VERSION  = '3.3.7'
 
   Button.DEFAULTS = {
     loadingText: 'loading...'
@@ -214,10 +214,10 @@ if (typeof jQuery === 'undefined') {
 
       if (state == 'loadingText') {
         this.isLoading = true
-        $el.addClass(d).attr(d, d)
+        $el.addClass(d).attr(d, d).prop(d, true)
       } else if (this.isLoading) {
         this.isLoading = false
-        $el.removeClass(d).removeAttr(d)
+        $el.removeClass(d).removeAttr(d).prop(d, false)
       }
     }, this), 0)
   }
@@ -281,10 +281,15 @@ if (typeof jQuery === 'undefined') {
 
   $(document)
     .on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) {
-      var $btn = $(e.target)
-      if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn')
+      var $btn = $(e.target).closest('.btn')
       Plugin.call($btn, 'toggle')
-      if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault()
+      if (!($(e.target).is('input[type="radio"], input[type="checkbox"]'))) {
+        // Prevent double click on radios, and the double selections (so cancellation) on checkboxes
+        e.preventDefault()
+        // The target component still receive the focus
+        if ($btn.is('input,button')) $btn.trigger('focus')
+        else $btn.find('input:visible,button:visible').first().trigger('focus')
+      }
     })
     .on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) {
       $(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type))
@@ -293,10 +298,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: carousel.js v3.3.6
+ * Bootstrap: carousel.js v3.3.7
  * http://getbootstrap.com/javascript/#carousel
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -324,7 +329,7 @@ if (typeof jQuery === 'undefined') {
       .on('mouseleave.bs.carousel', $.proxy(this.cycle, this))
   }
 
-  Carousel.VERSION  = '3.3.6'
+  Carousel.VERSION  = '3.3.7'
 
   Carousel.TRANSITION_DURATION = 600
 
@@ -531,13 +536,14 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: collapse.js v3.3.6
+ * Bootstrap: collapse.js v3.3.7
  * http://getbootstrap.com/javascript/#collapse
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
+/* jshint latedef: false */
 
 +function ($) {
   'use strict';
@@ -561,7 +567,7 @@ if (typeof jQuery === 'undefined') {
     if (this.options.toggle) this.toggle()
   }
 
-  Collapse.VERSION  = '3.3.6'
+  Collapse.VERSION  = '3.3.7'
 
   Collapse.TRANSITION_DURATION = 350
 
@@ -743,10 +749,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: dropdown.js v3.3.6
+ * Bootstrap: dropdown.js v3.3.7
  * http://getbootstrap.com/javascript/#dropdowns
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -763,7 +769,7 @@ if (typeof jQuery === 'undefined') {
     $(element).on('click.bs.dropdown', this.toggle)
   }
 
-  Dropdown.VERSION = '3.3.6'
+  Dropdown.VERSION = '3.3.7'
 
   function getParent($this) {
     var selector = $this.attr('data-target')
@@ -909,10 +915,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: modal.js v3.3.6
+ * Bootstrap: modal.js v3.3.7
  * http://getbootstrap.com/javascript/#modals
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -943,7 +949,7 @@ if (typeof jQuery === 'undefined') {
     }
   }
 
-  Modal.VERSION  = '3.3.6'
+  Modal.VERSION  = '3.3.7'
 
   Modal.TRANSITION_DURATION = 300
   Modal.BACKDROP_TRANSITION_DURATION = 150
@@ -1050,7 +1056,9 @@ if (typeof jQuery === 'undefined') {
     $(document)
       .off('focusin.bs.modal') // guard against infinite focus loop
       .on('focusin.bs.modal', $.proxy(function (e) {
-        if (this.$element[0] !== e.target && !this.$element.has(e.target).length) {
+        if (document !== e.target &&
+            this.$element[0] !== e.target &&
+            !this.$element.has(e.target).length) {
           this.$element.trigger('focus')
         }
       }, this))
@@ -1247,11 +1255,11 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: tooltip.js v3.3.6
+ * Bootstrap: tooltip.js v3.3.7
  * http://getbootstrap.com/javascript/#tooltip
  * Inspired by the original jQuery.tipsy by Jason Frame
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -1274,7 +1282,7 @@ if (typeof jQuery === 'undefined') {
     this.init('tooltip', element, options)
   }
 
-  Tooltip.VERSION  = '3.3.6'
+  Tooltip.VERSION  = '3.3.7'
 
   Tooltip.TRANSITION_DURATION = 150
 
@@ -1565,9 +1573,11 @@ if (typeof jQuery === 'undefined') {
 
     function complete() {
       if (that.hoverState != 'in') $tip.detach()
-      that.$element
-        .removeAttr('aria-describedby')
-        .trigger('hidden.bs.' + that.type)
+      if (that.$element) { // TODO: Check whether guarding this code with this `if` is really necessary.
+        that.$element
+          .removeAttr('aria-describedby')
+          .trigger('hidden.bs.' + that.type)
+      }
       callback && callback()
     }
 
@@ -1610,7 +1620,10 @@ if (typeof jQuery === 'undefined') {
       // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093
       elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top })
     }
-    var elOffset  = isBody ? { top: 0, left: 0 } : $element.offset()
+    var isSvg = window.SVGElement && el instanceof window.SVGElement
+    // Avoid using $.offset() on SVGs since it gives incorrect results in jQuery 3.
+    // See https://github.com/twbs/bootstrap/issues/20280
+    var elOffset  = isBody ? { top: 0, left: 0 } : (isSvg ? null : $element.offset())
     var scroll    = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() }
     var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null
 
@@ -1726,6 +1739,7 @@ if (typeof jQuery === 'undefined') {
       that.$tip = null
       that.$arrow = null
       that.$viewport = null
+      that.$element = null
     })
   }
 
@@ -1762,10 +1776,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: popover.js v3.3.6
+ * Bootstrap: popover.js v3.3.7
  * http://getbootstrap.com/javascript/#popovers
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -1782,7 +1796,7 @@ if (typeof jQuery === 'undefined') {
 
   if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js')
 
-  Popover.VERSION  = '3.3.6'
+  Popover.VERSION  = '3.3.7'
 
   Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, {
     placement: 'right',
@@ -1871,10 +1885,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: scrollspy.js v3.3.6
+ * Bootstrap: scrollspy.js v3.3.7
  * http://getbootstrap.com/javascript/#scrollspy
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -1900,7 +1914,7 @@ if (typeof jQuery === 'undefined') {
     this.process()
   }
 
-  ScrollSpy.VERSION  = '3.3.6'
+  ScrollSpy.VERSION  = '3.3.7'
 
   ScrollSpy.DEFAULTS = {
     offset: 10
@@ -2044,10 +2058,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: tab.js v3.3.6
+ * Bootstrap: tab.js v3.3.7
  * http://getbootstrap.com/javascript/#tabs
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -2064,7 +2078,7 @@ if (typeof jQuery === 'undefined') {
     // jscs:enable requireDollarBeforejQueryAssignment
   }
 
-  Tab.VERSION = '3.3.6'
+  Tab.VERSION = '3.3.7'
 
   Tab.TRANSITION_DURATION = 150
 
@@ -2200,10 +2214,10 @@ if (typeof jQuery === 'undefined') {
 }(jQuery);
 
 /* ========================================================================
- * Bootstrap: affix.js v3.3.6
+ * Bootstrap: affix.js v3.3.7
  * http://getbootstrap.com/javascript/#affix
  * ========================================================================
- * Copyright 2011-2015 Twitter, Inc.
+ * Copyright 2011-2016 Twitter, Inc.
  * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
  * ======================================================================== */
 
@@ -2229,7 +2243,7 @@ if (typeof jQuery === 'undefined') {
     this.checkPosition()
   }
 
-  Affix.VERSION  = '3.3.6'
+  Affix.VERSION  = '3.3.7'
 
   Affix.RESET    = 'affix affix-top affix-bottom'
 

File diff suppressed because it is too large
+ 7 - 0
netbox/project-static/bootstrap-3.3.7-dist/js/bootstrap.min.js


netbox/project-static/bootstrap-3.3.6-dist/js/npm.js → netbox/project-static/bootstrap-3.3.7-dist/js/npm.js


+ 3 - 0
netbox/project-static/css/base.css

@@ -92,6 +92,9 @@ tfoot td {
 table.attr-table td:nth-child(1) {
     width: 25%;
 }
+.table-headings th {
+    background-color: #f5f5f5;
+}
 
 /* Paginator */
 div.paginator {

BIN
netbox/project-static/font-awesome-4.6.3/fonts/FontAwesome.otf


BIN
netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.eot


File diff suppressed because it is too large
+ 0 - 685
netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.svg


+ 0 - 0
netbox/project-static/font-awesome-4.6.3/fonts/fontawesome-webfont.woff


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