diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a2b14446..2be85cf64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,10 @@ sure to include: * Any error messages generated * Screenshots (if applicable) +* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title. +The issue will be reviewed by a moderator after submission and the appropriate +labels will be applied. + * Keep in mind that we prioritize bugs based on their severity and how much work is required to resolve them. It may take some time for someone to address your issue. @@ -91,6 +95,10 @@ following: * Any third-party libraries or other resources which would be involved +* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue title. +The issue will be reviewed by a moderator after submission and the appropriate +labels will be applied. + ## Submitting Pull Requests * Be sure to open an issue before starting work on a pull request, and diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index dc28bb20b..000000000 --- a/Dockerfile +++ /dev/null @@ -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/"] diff --git a/README.md b/README.md index 66c35250b..d946215d5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https ### Build Status -| | python 2.7 | +NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended. + +| | status | |-------------|------------| | **master** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=master)](https://travis-ci.org/digitalocean/netbox) | | **develop** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=develop)](https://travis-ci.org/digitalocean/netbox) | @@ -29,5 +31,6 @@ 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)) +* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index d435066d6..000000000 --- a/docker-compose.yml +++ /dev/null @@ -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 diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh deleted file mode 100755 index 53e52ef04..000000000 --- a/docker/docker-entrypoint.sh +++ /dev/null @@ -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 diff --git a/docker/gunicorn_config.py b/docker/gunicorn_config.py deleted file mode 100644 index 878841ac0..000000000 --- a/docker/gunicorn_config.py +++ /dev/null @@ -1,5 +0,0 @@ -command = '/usr/bin/gunicorn' -pythonpath = '/opt/netbox/netbox' -bind = '0.0.0.0:8001' -workers = 3 -user = 'root' diff --git a/docker/nginx.conf b/docker/nginx.conf deleted file mode 100644 index 2a794f314..000000000 --- a/docker/nginx.conf +++ /dev/null @@ -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"'; - } - } -} diff --git a/docs/api-integration.md b/docs/api-integration.md deleted file mode 100644 index 99185adf6..000000000 --- a/docs/api-integration.md +++ /dev/null @@ -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. diff --git a/docs/api/authentication.md b/docs/api/authentication.md new file mode 100644 index 000000000..cb6da3bd1 --- /dev/null +++ b/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. diff --git a/docs/api/examples.md b/docs/api/examples.md new file mode 100644 index 000000000..5082534bc --- /dev/null +++ b/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. diff --git a/docs/api/overview.md b/docs/api/overview.md new file mode 100644 index 000000000..a9ad115f8 --- /dev/null +++ b/docs/api/overview.md @@ -0,0 +1,143 @@ +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": [...] +} +``` + +The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/optional-settings/#max_page_size) setting, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. + +!!! warning + Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. diff --git a/docs/api/working-with-secrets.md b/docs/api/working-with-secrets.md new file mode 100644 index 000000000..35091aaf6 --- /dev/null +++ b/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@" +{ + "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. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 9e466ddc1..05e60dcac 100644 --- a/docs/configuration/optional-settings.md +++ b/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 @@ -67,6 +83,34 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni --- +## LOGGING + +By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`. + +The Django framework on which NetBox runs allows for the customization of logging, e.g. to write logs to file. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/1.11/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a file: + +``` +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': '/var/log/netbox.log', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['file'], + 'level': 'INFO', + }, + }, +} +``` + +--- + ## LOGIN_REQUIRED Default: False @@ -83,6 +127,14 @@ Setting this to True will display a "maintenance mode" banner at the top of ever --- +## MAX_PAGE_SIZE + +Default: 1000 + +An API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This setting defines the maximum limit. Setting it to `0` or `None` will allow an API consumer to request all objects by specifying `?limit=0`. + +--- + ## NETBOX_USERNAME ## NETBOX_PASSWORD diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index a0b05e9dd..e4082ebc7 100644 --- a/docs/data-model/dcim.md +++ b/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. diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index ec424fec2..f4654c0dd 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -119,7 +119,14 @@ Each line of the **device patterns** field represents a hierarchical layer withi ``` core-switch-[abcd] dist-switch\d -access-switch\d+,oob-switch\d+ +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/`). diff --git a/docs/installation/docker.md b/docs/installation/docker.md deleted file mode 100644 index 00551a096..000000000 --- a/docs/installation/docker.md +++ /dev/null @@ -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 diff --git a/docs/installation/ldap.md b/docs/installation/ldap.md index 6a4994a5c..0d546863e 100644 --- a/docs/installation/ldap.md +++ b/docs/installation/ldap.md @@ -1,5 +1,4 @@ -This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to -built-in Django users in the event of a failure. +This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to built-in Django users in the event of a failure. # Requirements @@ -29,6 +28,9 @@ Create a file in the same directory as `configuration.py` (typically `netbox/net ## General Server Configuration +!!! info + When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure. + ```python import ldap @@ -52,6 +54,9 @@ LDAP_IGNORE_CERT_ERRORS = True ## User Authentication +!!! info + When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None. + ```python from django_auth_ldap.config import LDAPSearch @@ -99,3 +104,16 @@ AUTH_LDAP_FIND_GROUP_PERMS = True AUTH_LDAP_CACHE_GROUPS = True AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 ``` + +* `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in. +* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions. +* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions. + +It is also possible map user attributes to Django attributes: + +```python +AUTH_LDAP_USER_ATTR_MAP = { + "first_name": "givenName", + "last_name": "sn", +} +``` diff --git a/docs/installation/migrating-to-python3.md b/docs/installation/migrating-to-python3.md new file mode 100644 index 000000000..e99018252 --- /dev/null +++ b/docs/installation/migrating-to-python3.md @@ -0,0 +1,33 @@ +# Migration + +Remove Python 2 packages + +```no-highlight +# apt-get remove --purge -y python-dev python-pip +``` + +Install Python 3 packages + +```no-highlight +# apt-get install -y python3 python3-dev python3-pip +``` + +Install Python Packages + +```no-highlight +# cd /opt/netbox +# pip3 install -r requirements.txt +``` + +Gunicorn Update + +```no-highlight +# pip uninstall gunicorn +# pip3 install gunicorn +``` + +Re-install LDAP Module (optional if using LDAP for auth) + +```no-highlight +sudo pip3 install django-auth-ldap +``` diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index e47e92133..4befbeefc 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -1,26 +1,28 @@ # Installation -**Debian/Ubuntu** +**Ubuntu** Python 3: ```no-highlight -# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev +# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev ``` Python 2: ```no-highlight -# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev +# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev ``` -**CentOS/RHEL** +**CentOS** Python 3: ```no-highlight # yum install -y epel-release -# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel +# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel +# easy_install-3.4 pip +# ln -s -f python3.4 /usr/bin/python ``` Python 2: @@ -54,13 +56,13 @@ Create the base directory for the NetBox installation. For this guide, we'll use If `git` is not already installed, install it: -**Debian/Ubuntu** +**Ubuntu** ```no-highlight # apt-get install -y git ``` -**CentOS/RHEL** +**CentOS** ```no-highlight # yum install -y git @@ -83,10 +85,26 @@ Checking connectivity... done. Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.) +Python 3: + +```no-highlight +# pip3 install -r requirements.txt +``` + +Python 2: + ```no-highlight # pip install -r requirements.txt ``` +### NAPALM Automation + +As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: + +```no-highlight +# pip install napalm +``` + # Configuration Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. @@ -139,11 +157,14 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a # Run Database Migrations -Before NetBox can run, we need to install the database schema. This is done by running `./manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example): +!!! warning + The examples on the rest of this page call the `python` executable, which will be Python2 on most systems. Replace this with `python3` if you're running NetBox on Python3. + +Before NetBox can run, we need to install the database schema. This is done by running `python manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example): ```no-highlight # cd /opt/netbox/netbox/ -# ./manage.py migrate +# python manage.py migrate Operations to perform: Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users Running migrations: @@ -161,7 +182,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox: ```no-highlight -# ./manage.py createsuperuser +# python manage.py createsuperuser Username: admin Email address: admin@example.com Password: @@ -172,7 +193,7 @@ Superuser created successfully. # Collect Static Files ```no-highlight -# ./manage.py collectstatic +# python manage.py collectstatic --no-input You have requested to collect static files at the destination location as specified in your settings: @@ -193,7 +214,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch. ```no-highlight -# ./manage.py loaddata initial_data +# python manage.py loaddata initial_data Installed 43 object(s) from 4 fixture(s) ``` @@ -202,7 +223,7 @@ Installed 43 object(s) from 4 fixture(s) At this point, NetBox should be able to run. We can verify this by starting a development instance: ```no-highlight -# ./manage.py runserver 0.0.0.0:8000 --insecure +# python manage.py runserver 0.0.0.0:8000 --insecure Performing system checks... System check identified no issues (0 silenced). diff --git a/docs/installation/postgresql.md b/docs/installation/postgresql.md index 39a8f05cb..75c754707 100644 --- a/docs/installation/postgresql.md +++ b/docs/installation/postgresql.md @@ -1,17 +1,21 @@ NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).) +!!! note + The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 6.9. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. + # Installation -**Debian/Ubuntu** +**Ubuntu** ```no-highlight -# apt-get install -y postgresql libpq-dev python-psycopg2 +# apt-get update +# apt-get install -y postgresql libpq-dev ``` -**CentOS/RHEL** +**CentOS** ```no-highlight -# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2 +# yum install -y postgresql postgresql-server postgresql-devel # postgresql-setup initdb ``` diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 193d7e74a..02dbb878f 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -52,12 +52,27 @@ Once the new code is in place, run the upgrade script (which may need to be run # ./upgrade.sh ``` +!!! warning + The upgrade script will prefer Python3 and pip3 if both executables are available. To force it to use Python2 and pip, use the `-2` argument as below. + +```no-highlight +# ./upgrade.sh -2 +``` + This script: * Installs or upgrades any new required Python packages * Applies any database migrations that were included in the release * Collects all static files to be served by the HTTP service +!!! note + It's possible that the upgrade script will display a notice warning of unreflected database migrations: + + Your models have changes that are not yet reflected in a migration, and so won't be applied. + Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them. + + This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are intentionally modifying the database schema. + # Restart the WSGI Service Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`: diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index 6a058fddc..9da487f13 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/web-server.md @@ -3,7 +3,7 @@ We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll also use [supervisord](http://supervisord.org/) to enable service persistence. !!! info - Only Debian/Ubuntu instructions are provided here, but the installation process for CentOS/RHEL does not differ much. Please consult the documentation for those distributions for details. + For the sake of brevity, only Ubuntu 16.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed. ```no-highlight # apt-get install -y gunicorn supervisor @@ -25,7 +25,7 @@ server { server_name netbox.example.com; - access_log off; + client_max_body_size 25m; location /static/ { alias /opt/netbox/netbox/static/; @@ -73,6 +73,9 @@ Once Apache is installed, proceed with the following configuration (Be sure to m Alias /static /opt/netbox/netbox/static + # Needed to allow token-based API authentication + WSGIPassAuthorization on + Options Indexes FollowSymLinks MultiViews AllowOverride None diff --git a/docs/shell/intro.md b/docs/shell/intro.md new file mode 100644 index 000000000..df92cb7cd --- /dev/null +++ b/docs/shell/intro.md @@ -0,0 +1,194 @@ +NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command: + +``` +./manage.py nbshell +``` + +This will launch a customized version of [the built-in Django shell](https://docs.djangoproject.com/en/dev/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.) + +``` +$ ./manage.py nbshell +### NetBox interactive shell (jstretch-laptop) +### Python 2.7.6 | Django 1.11.3 | NetBox 2.1.0-dev +### lsmodels() will show available models. Use help() for more info. +``` + +The function `lsmodels()` will print a list of all available NetBox models: + +``` +>>> lsmodels() +DCIM: + ConsolePort + ConsolePortTemplate + ConsoleServerPort + ConsoleServerPortTemplate + Device + ... +``` + +## Querying Objects + +Objects are retrieved by forming a [Django queryset](https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `.objects.all()`, which will return a (truncated) list of all objects of that type. + +``` +>>> Device.objects.all() +, , , , , '...(remaining elements truncated)...']> +``` + +Use a `for` loop to cycle through all objects in the list: + +``` +>>> for device in Device.objects.all(): +... print(device.name, device.device_type) +... +(u'TestDevice1', ) +(u'TestDevice2', ) +(u'TestDevice3', ) +(u'TestDevice4', ) +(u'TestDevice5', ) +... +``` + +To count all objects matching the query, replace `all()` with `count()`: + +``` +>>> Device.objects.count() +1274 +``` + +To retrieve a particular object (typically by its primary key or other unique field), use `get()`: + +``` +>>> Site.objects.get(pk=7) + +``` + +### Filtering Querysets + +In most cases, you want to retrieve only a specific subset of objects. To filter a queryset, replace `all()` with `filter()` and pass one or more keyword arguments. For example: + +``` +>>> Device.objects.filter(status=STATUS_ACTIVE) +, , , , , '...(remaining elements truncated)...']> +``` + +Querysets support slicing to return a specific range of objects. + +``` +>>> Device.objects.filter(status=STATUS_ACTIVE)[:3] +, , ]> +``` + +The `count()` method can be appended to the queryset to return a count of objects rather than the full list. + +``` +>>> Device.objects.filter(status=STATUS_ACTIVE).count() +982 +``` + +Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper." + +``` +>>> Device.objects.filter(tenant__name='Pied Piper') +``` + +This approach can span multiple levels of relations. For example, the following will return all IP addresses assigned to a device in North America: + +``` +>>> IPAddress.objects.filter(interface__device__site__region__slug='north-america') +``` + +!!! note + While the above query is functional, it is very inefficient. There are ways to optimize such requests, however they are out of the scope of this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/dev/ref/models/querysets/) documentation. + +Reverse relationships can be traversed as well. For example, the following will find all devices with an interface named "em0": + +``` +>>> Device.objects.filter(interfaces__name='em0') +``` + +Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive). + +``` +>>> Device.objects.filter(name__icontains='testdevice') +``` + +Similarly, numeric fields can be filtered by values less than, greater than, and/or equal to a given value. + +``` +>>> VLAN.objects.filter(vid__gt=2000) +``` + +Multiple filters can be combined to further refine a queryset. + +``` +>>> VLAN.objects.filter(vid__gt=2000, name__icontains='engineering') +``` + +To return the inverse of a filtered queryset, use `exclude()` instead of `filter()`. + +``` +>>> Device.objects.count() +4479 +>>> Device.objects.filter(status=STATUS_ACTIVE).count() +4133 +>>> Device.objects.exclude(status=STATUS_ACTIVE).count() +346 +``` + +!!! info + The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API docs](https://docs.djangoproject.com/en/dev/ref/models/querysets/). + +## Creating and Updating Objects + +New objects can be created by instantiating the desired model, defining values for all required attributes, and calling `save()` on the instance. + +``` +>>> lab1 = Site.objects.get(pk=7) +>>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1) +>>> myvlan.save() +``` + +Alternatively, the above can be performed as a single operation: + +``` +>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save() +``` + +To modify an object, retrieve it, update the desired field(s), and call `save()` again. + +``` +>>> vlan = VLAN.objects.get(pk=1280) +>>> vlan.name +u'MyNewVLAN' +>>> vlan.name = 'BetterName' +>>> vlan.save() +>>> VLAN.objects.get(pk=1280).name +u'BetterName' +``` + +!!! warning + The Django ORM provides methods to create/edit many objects at once, namely `bulk_create()` and `update()`. These are best avoided in most cases as they bypass a model's built-in validation and can easily lead to database corruption if not used carefully. + +## Deleting Objects + +To delete an object, simply call `delete()` on its instance. This will return a dictionary of all objects (including related objects) which have been deleted as a result of this operation. + +``` +>>> vlan + +>>> vlan.delete() +(1, {u'extras.CustomFieldValue': 0, u'ipam.VLAN': 1}) +``` + +To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them. + +``` +>>> Device.objects.filter(name__icontains='test').count() +27 +>>> Device.objects.filter(name__icontains='test').delete() +(35, {u'extras.CustomFieldValue': 0, u'dcim.DeviceBay': 0, u'secrets.Secret': 0, u'dcim.InterfaceConnection': 4, u'extras.ImageAttachment': 0, u'dcim.Device': 27, u'dcim.Interface': 4, u'dcim.ConsolePort': 0, u'dcim.PowerPort': 0}) +``` + +!!! warning + Deletions are immediate and irreversible. Always think very carefully before calling `delete()` on an instance or queryset. diff --git a/mkdocs.yml b/mkdocs.yml index 9a96fe0f7..f204749d5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,7 @@ pages: - 'Web Server': 'installation/web-server.md' - 'LDAP (Optional)': 'installation/ldap.md' - 'Upgrading': 'installation/upgrading.md' - - 'Alternate Install: Docker': 'installation/docker.md' + - 'Migrating to Python3': 'installation/migrating-to-python3.md' - 'Configuration': - 'Mandatory Settings': 'configuration/mandatory-settings.md' - 'Optional Settings': 'configuration/optional-settings.md' @@ -19,7 +19,13 @@ 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' + - 'Shell': + - 'Introduction': 'shell/intro.md' markdown_extensions: - admonition: diff --git a/netbox/circuits/admin.py b/netbox/circuits/admin.py deleted file mode 100644 index 281ed2104..000000000 --- a/netbox/circuits/admin.py +++ /dev/null @@ -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') diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 947aa9860..cdab3427a 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,72 +1,120 @@ +from __future__ import unicode_literals + 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 +from utilities.api import ModelValidationMixin # # 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', + ] # # Circuit types # -class CircuitTypeSerializer(serializers.ModelSerializer): +class CircuitTypeSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = CircuitType fields = ['id', 'name', 'slug'] -class CircuitTypeNestedSerializer(CircuitTypeSerializer): +class NestedCircuitTypeSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') - class Meta(CircuitTypeSerializer.Meta): - pass + class Meta: + model = CircuitType + fields = ['id', 'url', 'name', 'slug'] # # Circuits # -class CircuitTerminationSerializer(serializers.ModelSerializer): - site = SiteNestedSerializer() - interface = InterfaceNestedSerializer() - - class Meta: - model = CircuitTermination - fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info'] - - -class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): - provider = ProviderNestedSerializer() - type = CircuitTypeNestedSerializer() - tenant = TenantNestedSerializer() - terminations = CircuitTerminationSerializer(many=True) +class CircuitSerializer(CustomFieldModelSerializer): + provider = NestedProviderSerializer() + type = NestedCircuitTypeSerializer() + tenant = NestedTenantSerializer() class Meta: model = Circuit - fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', - 'terminations', 'custom_fields'] + fields = [ + 'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'custom_fields', + ] -class CircuitNestedSerializer(CircuitSerializer): +class NestedCircuitSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') - class Meta(CircuitSerializer.Meta): - fields = ['id', 'cid'] + class Meta: + model = Circuit + fields = ['id', 'url', 'cid'] + + +class WritableCircuitSerializer(CustomFieldModelSerializer): + + 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(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = CircuitTermination + fields = [ + 'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index afc034141..25df44bfd 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,25 +1,28 @@ -from django.conf.urls import url +from __future__ import unicode_literals -from extras.models import GRAPH_TYPE_PROVIDER -from extras.api.views import GraphListView +from rest_framework import routers -from .views import * +from . import views -urlpatterns = [ +class CircuitsRootView(routers.APIRootView): + """ + Circuits API root view + """ + def get_view_name(self): + return 'Circuits' - # Providers - url(r'^providers/$', ProviderListView.as_view(), name='provider_list'), - url(r'^providers/(?P\d+)/$', ProviderDetailView.as_view(), name='provider_detail'), - url(r'^providers/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER}, - name='provider_graphs'), - # Circuit types - url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'), - url(r'^circuit-types/(?P\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'), +router = routers.DefaultRouter() +router.APIRootView = CircuitsRootView - # Circuits - url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'), - url(r'^circuits/(?P\d+)/$', CircuitDetailView.as_view(), name='circuit_detail'), +# Providers +router.register(r'providers', views.ProviderViewSet) -] +# Circuits +router.register(r'circuit-types', views.CircuitTypeViewSet) +router.register(r'circuits', views.CircuitViewSet) +router.register(r'circuit-terminations', views.CircuitTerminationViewSet) + +app_name = 'circuits-api' +urlpatterns = router.urls diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index d89286036..685fa8f9e 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,58 +1,68 @@ -from rest_framework import generics +from __future__ import unicode_literals -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 django.shortcuts import get_object_or_404 + +from circuits import filters +from circuits.models import Provider, CircuitTermination, CircuitType, Circuit +from extras.models import Graph, GRAPH_TYPE_PROVIDER +from extras.api.serializers import RenderedGraphSerializer +from extras.api.views import CustomFieldModelViewSet +from utilities.api import WritableSerializerMixin from . import serializers -class ProviderListView(CustomFieldModelAPIView, generics.ListAPIView): - """ - List all providers - """ - queryset = Provider.objects.prefetch_related('custom_field_values__field') +# +# Providers +# + +class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet): + queryset = Provider.objects.all() serializer_class = serializers.ProviderSerializer + write_serializer_class = serializers.WritableProviderSerializer + filter_class = filters.ProviderFilter + + @detail_route() + def graphs(self, request, pk=None): + """ + A convenience method for rendering graphs for a particular provider. + """ + provider = get_object_or_404(Provider, pk=pk) + queryset = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER) + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider}) + return Response(serializer.data) -class ProviderDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single provider - """ - queryset = Provider.objects.prefetch_related('custom_field_values__field') - serializer_class = serializers.ProviderSerializer +# +# Circuit Types +# - -class CircuitTypeListView(generics.ListAPIView): - """ - List all circuit types - """ +class CircuitTypeViewSet(ModelViewSet): queryset = CircuitType.objects.all() serializer_class = serializers.CircuitTypeSerializer + filter_class = filters.CircuitTypeFilter -class CircuitTypeDetailView(generics.RetrieveAPIView): - """ - Retrieve a single circuit type - """ - queryset = CircuitType.objects.all() - serializer_class = serializers.CircuitTypeSerializer +# +# Circuits +# - -class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView): - """ - List circuits (filterable) - """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\ - .prefetch_related('custom_field_values__field') +class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet): + queryset = Circuit.objects.select_related('type', 'tenant', 'provider') serializer_class = serializers.CircuitSerializer - filter_class = CircuitFilter + write_serializer_class = serializers.WritableCircuitSerializer + filter_class = filters.CircuitFilter -class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single circuit - """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\ - .prefetch_related('custom_field_values__field') - serializer_class = serializers.CircuitSerializer +# +# Circuit Terminations +# + +class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet): + queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device') + serializer_class = serializers.CircuitTerminationSerializer + write_serializer_class = serializers.WritableCircuitTerminationSerializer + filter_class = filters.CircuitTerminationFilter diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py index bc0b7d87d..613c347f2 100644 --- a/netbox/circuits/apps.py +++ b/netbox/circuits/apps.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django.apps import AppConfig diff --git a/netbox/circuits/constants.py b/netbox/circuits/constants.py new file mode 100644 index 000000000..816e28e4e --- /dev/null +++ b/netbox/circuits/constants.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals + + +# CircuitTermination sides +TERM_SIDE_A = 'A' +TERM_SIDE_Z = 'Z' +TERM_SIDE_CHOICES = ( + (TERM_SIDE_A, 'A'), + (TERM_SIDE_Z, 'Z'), +) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 087512028..8a1b01a89 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import django_filters from django.db.models import Q @@ -6,8 +8,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): @@ -30,7 +31,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Provider - fields = ['name', 'account', 'asn'] + fields = ['name', 'slug', 'asn', 'account'] def search(self, queryset, name, value): if not value.strip(): @@ -38,10 +39,19 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter( Q(name__icontains=value) | Q(account__icontains=value) | + Q(noc_contact__icontains=value) | + Q(admin_contact__icontains=value) | Q(comments__icontains=value) ) +class CircuitTypeFilter(django_filters.FilterSet): + + class Meta: + model = CircuitType + fields = ['name', 'slug'] + + class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -49,7 +59,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) provider_id = django_filters.ModelMultipleChoiceFilter( - name='provider', queryset=Provider.objects.all(), label='Provider (ID)', ) @@ -60,7 +69,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Provider (slug)', ) type_id = django_filters.ModelMultipleChoiceFilter( - name='type', queryset=CircuitType.objects.all(), label='Circuit type (ID)', ) @@ -71,7 +79,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Circuit type (slug)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -95,7 +102,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Circuit - fields = ['install_date'] + fields = ['cid', 'install_date', 'commit_rate'] def search(self, queryset, name, value): if not value.strip(): @@ -107,3 +114,37 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() + + +class CircuitTerminationFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + circuit_id = django_filters.ModelMultipleChoiceFilter( + queryset=Circuit.objects.all(), + label='Circuit', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + + class Meta: + model = CircuitTermination + fields = ['term_side', 'port_speed', 'upstream_speed', 'xconnect_id'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(circuit__cid__icontains=value) | + Q(xconnect_id__icontains=value) | + Q(pp_info__icontains=value) + ).distinct() diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 940ae939a..d9954e55b 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,12 +1,15 @@ +from __future__ import unicode_literals + from django import forms from django.db.models import Count -from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES +from dcim.models import Site, Device, Interface, Rack from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea, - SlugField, + APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField, + SmallTextarea, SlugField, ) from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -36,15 +39,18 @@ class ProviderForm(BootstrapMixin, CustomFieldForm): } -class ProviderFromCSVForm(forms.ModelForm): +class ProviderCSVForm(forms.ModelForm): + slug = SlugField() class Meta: model = Provider - fields = ['name', 'slug', 'asn', 'account', 'portal_url'] - - -class ProviderImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ProviderFromCSVForm) + fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments'] + help_texts = { + 'name': 'Provider name', + 'asn': '32-bit autonomous system number', + 'portal_url': 'Portal URL', + 'comments': 'Free-form comments', + } class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -83,12 +89,15 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): # Circuits # -class CircuitForm(BootstrapMixin, CustomFieldForm): +class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm): comments = CommentField() class Meta: model = Circuit - fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] + fields = [ + 'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', + 'comments', + ] help_texts = { 'cid': "Unique circuit ID", 'install_date': "Format: YYYY-MM-DD", @@ -96,21 +105,36 @@ class CircuitForm(BootstrapMixin, CustomFieldForm): } -class CircuitFromCSVForm(forms.ModelForm): - provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Provider not found.'}) - type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid circuit type.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) +class CircuitCSVForm(forms.ModelForm): + provider = forms.ModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Name of parent provider', + error_messages={ + 'invalid_choice': 'Provider not found.' + } + ) + type = forms.ModelChoiceField( + queryset=CircuitType.objects.all(), + to_field_name='name', + help_text='Type of circuit', + error_messages={ + 'invalid_choice': 'Invalid circuit type.' + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.' + } + ) class Meta: model = Circuit - fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description'] - - -class CircuitImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=CircuitFromCSVForm) + fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments'] class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -152,15 +176,18 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): # Circuit terminations # -class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): +class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), widget=forms.Select( attrs={'filter-for': 'rack'} ) ) - rack = forms.ModelChoiceField( + rack = ChainedModelChoiceField( queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), required=False, label='Rack', widget=APISelect( @@ -168,8 +195,12 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): attrs={'filter-for': 'device', 'nullable': 'true'} ) ) - device = forms.ModelChoiceField( + device = ChainedModelChoiceField( queryset=Device.objects.all(), + chains=( + ('site', 'site'), + ('rack', 'rack'), + ), required=False, label='Device', widget=APISelect( @@ -178,29 +209,27 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): attrs={'filter-for': 'interface'} ) ) - livesearch = forms.CharField( - required=False, - label='Device', - widget=Livesearch( - query_key='q', - query_url='dcim-api:device_list', - field_to_update='device' - ) - ) - interface = forms.ModelChoiceField( - queryset=Interface.objects.all(), + interface = ChainedModelChoiceField( + queryset=Interface.objects.connectable().select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ), + chains=( + ('device', 'device'), + ), 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' ) ) class Meta: model = CircuitTermination - fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info'] + fields = [ + 'term_side', 'site', 'rack', 'device', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', + 'pp_info', + ] help_texts = { 'port_speed': "Physical circuit speed", 'xconnect_id': "ID of the local cross-connect", @@ -212,49 +241,22 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): + # Initialize helper selectors + instance = kwargs.get('instance') + if instance and instance.interface is not None: + initial = kwargs.get('initial', {}).copy() + initial['rack'] = instance.interface.device.rack + initial['device'] = instance.interface.device + kwargs['initial'] = initial + super(CircuitTerminationForm, self).__init__(*args, **kwargs) - # If an interface has been assigned, initialize rack and device - if self.instance.interface: - self.initial['rack'] = self.instance.interface.device.rack - self.initial['device'] = self.instance.interface.device - - # Limit rack choices - if self.is_bound: - self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Limit device choices - if self.is_bound and self.data.get('rack'): - self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack']) - elif self.initial.get('rack'): - self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) - else: - self.fields['device'].choices = [] - - # Limit interface choices - if self.is_bound and self.data.get('device'): - interfaces = Interface.objects.filter(device=self.data['device']).exclude( - form_factor__in=VIRTUAL_IFACE_TYPES - ).select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' + # Mark connected interfaces as disabled + self.fields['interface'].choices = [] + for iface in self.fields['interface'].queryset: + self.fields['interface'].choices.append( + (iface.id, { + 'label': iface.name, + 'disabled': iface.is_connected and iface.pk != self.initial.get('interface'), + }) ) - self.fields['interface'].widget.attrs['initial'] = self.data.get('interface') - elif self.initial.get('device'): - interfaces = Interface.objects.filter(device=self.initial['device']).exclude( - form_factor__in=VIRTUAL_IFACE_TYPES - ).select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ) - self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface') - else: - interfaces = [] - self.fields['interface'].choices = [ - (iface.id, { - 'label': iface.name, - 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'), - }) for iface in interfaces - ] diff --git a/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py b/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py new file mode 100644 index 000000000..14ee6686d --- /dev/null +++ b/netbox/circuits/migrations/0008_circuittermination_interface_protect_on_delete.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-04-19 17:17 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0007_circuit_add_description'), + ] + + operations = [ + migrations.AlterField( + model_name='circuittermination', + name='interface', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface'), + ), + ] diff --git a/netbox/circuits/migrations/0009_unicode_literals.py b/netbox/circuits/migrations/0009_unicode_literals.py new file mode 100644 index 000000000..0f22a2268 --- /dev/null +++ b/netbox/circuits/migrations/0009_unicode_literals.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-05-24 15:34 +from __future__ import unicode_literals + +import dcim.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0008_circuittermination_interface_protect_on_delete'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='cid', + field=models.CharField(max_length=50, verbose_name='Circuit ID'), + ), + migrations.AlterField( + model_name='circuit', + name='commit_rate', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)'), + ), + migrations.AlterField( + model_name='circuit', + name='install_date', + field=models.DateField(blank=True, null=True, verbose_name='Date installed'), + ), + migrations.AlterField( + model_name='circuittermination', + name='port_speed', + field=models.PositiveIntegerField(verbose_name='Port speed (Kbps)'), + ), + migrations.AlterField( + model_name='circuittermination', + name='pp_info', + field=models.CharField(blank=True, max_length=100, verbose_name='Patch panel/port(s)'), + ), + migrations.AlterField( + model_name='circuittermination', + name='term_side', + field=models.CharField(choices=[('A', 'A'), ('Z', 'Z')], max_length=1, verbose_name='Termination'), + ), + migrations.AlterField( + model_name='circuittermination', + name='upstream_speed', + field=models.PositiveIntegerField(blank=True, help_text='Upstream speed, if different from port speed', null=True, verbose_name='Upstream speed (Kbps)'), + ), + migrations.AlterField( + model_name='circuittermination', + name='xconnect_id', + field=models.CharField(blank=True, max_length=50, verbose_name='Cross-connect ID'), + ), + migrations.AlterField( + model_name='provider', + name='account', + field=models.CharField(blank=True, max_length=30, verbose_name='Account number'), + ), + migrations.AlterField( + model_name='provider', + name='admin_contact', + field=models.TextField(blank=True, verbose_name='Admin contact'), + ), + migrations.AlterField( + model_name='provider', + name='asn', + field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'), + ), + migrations.AlterField( + model_name='provider', + name='noc_contact', + field=models.TextField(blank=True, verbose_name='NOC contact'), + ), + migrations.AlterField( + model_name='provider', + name='portal_url', + field=models.URLField(blank=True, verbose_name='Portal'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 6a0380dd5..1acd3f4a0 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,6 +1,8 @@ +from __future__ import unicode_literals + 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 @@ -8,14 +10,7 @@ from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.utils import csv_format from utilities.models import CreatedUpdatedModel - - -TERM_SIDE_A = 'A' -TERM_SIDE_Z = 'Z' -TERM_SIDE_CHOICES = ( - (TERM_SIDE_A, 'A'), - (TERM_SIDE_Z, 'Z'), -) +from .constants import * def humanize_speed(speed): @@ -50,6 +45,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel): comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url'] + class Meta: ordering = ['name'] @@ -105,12 +102,14 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description'] + class Meta: ordering = ['provider', 'cid'] unique_together = ['provider', 'cid'] def __str__(self): - return u'{} {}'.format(self.provider, self.cid) + return '{} {}'.format(self.provider, self.cid) def get_absolute_url(self): return reverse('circuits:circuit', args=[self.pk]) @@ -150,10 +149,14 @@ class CircuitTermination(models.Model): circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE) term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination') site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT) - interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True) + interface = models.OneToOneField( + 'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT + ) port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)') - upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)', - help_text='Upstream speed, if different from port speed') + upstream_speed = models.PositiveIntegerField( + blank=True, null=True, verbose_name='Upstream speed (Kbps)', + help_text='Upstream speed, if different from port speed' + ) xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') @@ -162,7 +165,7 @@ class CircuitTermination(models.Model): unique_together = ['circuit', 'term_side'] def __str__(self): - return u'{} (Side {})'.format(self.circuit, self.get_term_side_display()) + return '{} (Side {})'.format(self.circuit, self.get_term_side_display()) def get_peer_termination(self): peer_side = 'Z' if self.term_side == 'A' else 'A' diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index bdfe8c0b6..40a1e1031 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django.db.models.signals import post_delete, post_save from django.dispatch import receiver from django.utils import timezone diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index ab877a8ce..58775b378 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,8 +1,9 @@ +from __future__ import unicode_literals + import django_tables2 as tables from django_tables2.utils import Accessor from utilities.tables import BaseTable, ToggleColumn - from .models import Circuit, CircuitType, Provider @@ -19,12 +20,17 @@ 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') - circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits') + name = tables.LinkColumn() class Meta(BaseTable.Meta): + model = Provider + fields = ('pk', 'name', 'asn', 'account',) + + +class ProviderDetailTable(ProviderTable): + circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits') + + class Meta(ProviderTable.Meta): model = Provider fields = ('pk', 'name', 'asn', 'account', 'circuit_count') @@ -35,11 +41,11 @@ class ProviderTable(BaseTable): 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,15 +58,17 @@ 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 diff --git a/netbox/circuits/tests/__init__.py b/netbox/circuits/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py new file mode 100644 index 000000000..fc39b72de --- /dev/null +++ b/netbox/circuits/tests/test_api.py @@ -0,0 +1,331 @@ +from __future__ import unicode_literals + +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) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 7dd00b268..7dd72ad9d 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,39 +1,42 @@ +from __future__ import unicode_literals + from django.conf.urls import url from . import views +app_name = 'circuits' urlpatterns = [ # Providers url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'), - url(r'^providers/add/$', views.ProviderEditView.as_view(), name='provider_add'), + url(r'^providers/add/$', views.ProviderCreateView.as_view(), name='provider_add'), url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'), url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - url(r'^providers/(?P[\w-]+)/$', views.provider, name='provider'), + url(r'^providers/(?P[\w-]+)/$', views.ProviderView.as_view(), name='provider'), url(r'^providers/(?P[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'), url(r'^providers/(?P[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'), # Circuit types url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), - url(r'^circuit-types/add/$', views.CircuitTypeEditView.as_view(), name='circuittype_add'), + url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), url(r'^circuit-types/(?P[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), # Circuits url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'), - url(r'^circuits/add/$', views.CircuitEditView.as_view(), name='circuit_add'), + url(r'^circuits/add/$', views.CircuitCreateView.as_view(), name='circuit_add'), url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'), url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), - url(r'^circuits/(?P\d+)/$', views.circuit, name='circuit'), + url(r'^circuits/(?P\d+)/$', views.CircuitView.as_view(), name='circuit'), url(r'^circuits/(?P\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), url(r'^circuits/(?P\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), url(r'^circuits/(?P\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations - url(r'^circuits/(?P\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), + url(r'^circuits/(?P\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), url(r'^circuit-terminations/(?P\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), url(r'^circuit-terminations/(?P\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 1ffda899b..345e3379d 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,17 +1,19 @@ +from __future__ import unicode_literals + 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 django.views.generic import View from extras.models import Graph, GRAPH_TYPE_PROVIDER from utilities.forms import ConfirmationForm from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) - from . import filters, forms, tables from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z @@ -24,32 +26,41 @@ class ProviderListView(ObjectListView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filter = filters.ProviderFilter filter_form = forms.ProviderFilterForm - table = tables.ProviderTable + table = tables.ProviderDetailTable template_name = 'circuits/provider_list.html' -def provider(request, slug): +class ProviderView(View): - provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\ - .prefetch_related('terminations__site') - show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() + def get(self, request, slug): - return render(request, 'circuits/provider.html', { - 'provider': provider, - 'circuits': circuits, - 'show_graphs': show_graphs, - }) + provider = get_object_or_404(Provider, slug=slug) + circuits = Circuit.objects.filter(provider=provider).select_related( + 'type', 'tenant' + ).prefetch_related( + 'terminations__site' + ) + show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() + + return render(request, 'circuits/provider.html', { + 'provider': provider, + 'circuits': circuits, + 'show_graphs': show_graphs, + }) -class ProviderEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.change_provider' +class ProviderCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'circuits.add_provider' model = Provider form_class = forms.ProviderForm template_name = 'circuits/provider_edit.html' default_return_url = 'circuits:provider_list' +class ProviderEditView(ProviderCreateView): + permission_required = 'circuits.change_provider' + + class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_provider' model = Provider @@ -58,9 +69,8 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'circuits.add_provider' - form = forms.ProviderImportForm + model_form = forms.ProviderCSVForm table = tables.ProviderTable - template_name = 'circuits/provider_import.html' default_return_url = 'circuits:provider_list' @@ -68,8 +78,8 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_provider' cls = Provider filter = filters.ProviderFilter + table = tables.ProviderTable form = forms.ProviderBulkEditForm - template_name = 'circuits/provider_bulk_edit.html' default_return_url = 'circuits:provider_list' @@ -77,6 +87,7 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_provider' cls = Provider filter = filters.ProviderFilter + table = tables.ProviderTable default_return_url = 'circuits:provider_list' @@ -90,18 +101,24 @@ class CircuitTypeListView(ObjectListView): template_name = 'circuits/circuittype_list.html' -class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.change_circuittype' +class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'circuits.add_circuittype' model = CircuitType form_class = forms.CircuitTypeForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('circuits:circuittype_list') +class CircuitTypeEditView(CircuitTypeCreateView): + permission_required = 'circuits.change_circuittype' + + class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' cls = CircuitType + queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) + table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' @@ -117,36 +134,41 @@ class CircuitListView(ObjectListView): template_name = 'circuits/circuit_list.html' -def circuit(request, pk): +class CircuitView(View): - circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) - termination_a = CircuitTermination.objects.select_related( - 'site__region', 'interface__device' - ).filter( - circuit=circuit, term_side=TERM_SIDE_A - ).first() - termination_z = CircuitTermination.objects.select_related( - 'site__region', 'interface__device' - ).filter( - circuit=circuit, term_side=TERM_SIDE_Z - ).first() + def get(self, request, pk): - return render(request, 'circuits/circuit.html', { - 'circuit': circuit, - 'termination_a': termination_a, - 'termination_z': termination_z, - }) + circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) + termination_a = CircuitTermination.objects.select_related( + 'site__region', 'interface__device' + ).filter( + circuit=circuit, term_side=TERM_SIDE_A + ).first() + termination_z = CircuitTermination.objects.select_related( + 'site__region', 'interface__device' + ).filter( + circuit=circuit, term_side=TERM_SIDE_Z + ).first() + + return render(request, 'circuits/circuit.html', { + 'circuit': circuit, + 'termination_a': termination_a, + 'termination_z': termination_z, + }) -class CircuitEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.change_circuit' +class CircuitCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'circuits.add_circuit' model = Circuit form_class = forms.CircuitForm - fields_initial = ['provider'] template_name = 'circuits/circuit_edit.html' default_return_url = 'circuits:circuit_list' +class CircuitEditView(CircuitCreateView): + permission_required = 'circuits.change_circuit' + + class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_circuit' model = Circuit @@ -155,25 +177,27 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'circuits.add_circuit' - form = forms.CircuitImportForm + model_form = forms.CircuitCSVForm table = tables.CircuitTable - template_name = 'circuits/circuit_import.html' default_return_url = 'circuits:circuit_list' class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_circuit' cls = Circuit + queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter + table = tables.CircuitTable form = forms.CircuitBulkEditForm - template_name = 'circuits/circuit_bulk_edit.html' default_return_url = 'circuits:circuit_list' class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' cls = Circuit + queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter + table = tables.CircuitTable default_return_url = 'circuits:circuit_list' @@ -226,11 +250,10 @@ def circuit_terminations_swap(request, pk): # Circuit terminations # -class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'circuits.change_circuittermination' +class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'circuits.add_circuittermination' model = CircuitTermination form_class = forms.CircuitTerminationForm - fields_initial = ['term_side'] template_name = 'circuits/circuittermination_edit.html' def alter_obj(self, obj, request, url_args, url_kwargs): @@ -238,10 +261,14 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView): obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit']) return obj - def get_return_url(self, obj): + def get_return_url(self, request, obj): return obj.circuit.get_absolute_url() +class CircuitTerminationEditView(CircuitTerminationCreateView): + permission_required = 'circuits.change_circuittermination' + + class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'circuits.delete_circuittermination' model = CircuitTermination diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py deleted file mode 100644 index 16f07dfcf..000000000 --- a/netbox/dcim/admin.py +++ /dev/null @@ -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' diff --git a/netbox/dcim/api/exceptions.py b/netbox/dcim/api/exceptions.py index 05ad86b5b..8804da436 100644 --- a/netbox/dcim/api/exceptions.py +++ b/netbox/dcim/api/exceptions.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from rest_framework.exceptions import APIException diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d6162f48f..50bf756e3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,28 +1,44 @@ +from __future__ import unicode_literals +from collections import OrderedDict + from rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator from ipam.models import IPAddress +from circuits.models import Circuit, CircuitTermination 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, ModelValidationMixin # # 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(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Region @@ -33,21 +49,35 @@ class RegionSerializer(serializers.ModelSerializer): # Sites # -class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): - region = RegionNestedSerializer() - tenant = TenantNestedSerializer() +class SiteSerializer(CustomFieldModelSerializer): + region = NestedRegionSerializer() + tenant = NestedTenantSerializer() class Meta: model = Site - fields = ['id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', - 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', - 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] + fields = [ + 'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', + 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', + ] -class SiteNestedSerializer(SiteSerializer): +class NestedSiteSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') - class Meta(SiteSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = Site + fields = ['id', 'url', 'name', 'slug'] + + +class WritableSiteSerializer(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,85 +85,123 @@ 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(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = RackGroup + fields = ['id', 'name', 'slug', 'site'] # # Rack roles # -class RackRoleSerializer(serializers.ModelSerializer): +class RackRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RackRole fields = ['id', 'name', 'slug', 'color'] -class RackRoleNestedSerializer(RackRoleSerializer): +class NestedRackRoleSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') - class Meta(RackRoleSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = RackRole + fields = ['id', 'url', 'name', 'slug'] # # Racks # -class RackReservationNestedSerializer(serializers.ModelSerializer): - - class Meta: - model = RackReservation - fields = ['id', 'units', 'created', 'user', 'description'] - - -class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): - site = SiteNestedSerializer() - group = RackGroupNestedSerializer() - tenant = TenantNestedSerializer() - role = RackRoleNestedSerializer() +class RackSerializer(CustomFieldModelSerializer): + site = NestedSiteSerializer() + group = NestedRackGroupSerializer() + tenant = NestedTenantSerializer() + role = NestedRackRoleSerializer() + type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) + width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) class Meta: model = Rack - fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields'] + fields = [ + 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', + 'desc_units', 'comments', 'custom_fields', + ] -class RackNestedSerializer(RackSerializer): +class NestedRackSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') - class Meta(RackSerializer.Meta): - fields = ['id', 'name', 'facility_id', 'display_name'] + class Meta: + model = Rack + fields = ['id', 'url', 'name', 'display_name'] -class RackDetailSerializer(RackSerializer): - front_units = serializers.SerializerMethodField() - rear_units = serializers.SerializerMethodField() - reservations = RackReservationNestedSerializer(many=True) +class WritableRackSerializer(CustomFieldModelSerializer): - class Meta(RackSerializer.Meta): - fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'desc_units', 'reservations', 'comments', 'custom_fields', 'front_units', 'rear_units'] + class Meta: + model = Rack + fields = [ + 'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + 'comments', '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 get_front_units(self, obj): - units = obj.get_rack_units(face=RACK_FACE_FRONT) - for u in units: - u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None - return units + def validate(self, data): - def get_rear_units(self, obj): - units = obj.get_rack_units(face=RACK_FACE_REAR) - for u in units: - u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None - return units + # Validate uniqueness of (site, facility_id) since we omitted the automatically-created validator from Meta. + if data.get('facility_id', None): + validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('site', 'facility_id')) + validator.set_context(self) + validator(data) + + # Enforce model validation + super(WritableRackSerializer, self).validate(data) + + return data + + +# +# Rack units +# + +class NestedDeviceSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') + + class Meta: + model = Device + fields = ['id', 'url', 'name', 'display_name'] + + +class RackUnitSerializer(serializers.Serializer): + """ + A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. + """ + id = serializers.IntegerField(read_only=True) + name = serializers.CharField(read_only=True) + face = serializers.IntegerField(read_only=True) + device = NestedDeviceSerializer(read_only=True) # @@ -141,164 +209,255 @@ class RackDetailSerializer(RackSerializer): # class RackReservationSerializer(serializers.ModelSerializer): - rack = RackNestedSerializer() + rack = NestedRackSerializer() class Meta: model = RackReservation fields = ['id', 'rack', 'units', 'created', 'user', 'description'] +class WritableRackReservationSerializer(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = RackReservation + fields = ['id', 'rack', 'units', 'description'] + + # # Manufacturers # -class ManufacturerSerializer(serializers.ModelSerializer): +class ManufacturerSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Manufacturer fields = ['id', 'name', 'slug'] -class ManufacturerNestedSerializer(ManufacturerSerializer): +class NestedManufacturerSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') - class Meta(ManufacturerSerializer.Meta): - pass + class Meta: + model = Manufacturer + fields = ['id', 'url', 'name', 'slug'] # # Device types # -class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer): - manufacturer = ManufacturerNestedSerializer() - subdevice_role = serializers.SerializerMethodField() +class DeviceTypeSerializer(CustomFieldModelSerializer): + manufacturer = NestedManufacturerSerializer() + interface_ordering = ChoiceFieldSerializer(choices=IFACE_ORDERING_CHOICES) + subdevice_role = ChoiceFieldSerializer(choices=SUBDEVICE_ROLE_CHOICES) instance_count = serializers.IntegerField(source='instances.count', read_only=True) class Meta: model = DeviceType - fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', - 'comments', 'custom_fields', 'instance_count'] - - def get_subdevice_role(self, obj): - return { - SUBDEVICE_ROLE_PARENT: 'parent', - SUBDEVICE_ROLE_CHILD: 'child', - None: None, - }[obj.subdevice_role] + fields = [ + 'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering', + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', + 'instance_count', + ] -class DeviceTypeNestedSerializer(DeviceTypeSerializer): +class NestedDeviceTypeSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') + manufacturer = NestedManufacturerSerializer() - class Meta(DeviceTypeSerializer.Meta): - fields = ['id', 'manufacturer', 'model', 'slug'] + class Meta: + model = DeviceType + fields = ['id', 'url', 'manufacturer', 'model', 'slug'] -class ConsolePortTemplateNestedSerializer(serializers.ModelSerializer): +class WritableDeviceTypeSerializer(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', + ] + + +# +# 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(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = ConsolePortTemplate + fields = ['id', 'device_type', 'name'] + + +# +# Console server port templates +# + +class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'name'] + fields = ['id', 'device_type', 'name'] -class PowerPortTemplateNestedSerializer(serializers.ModelSerializer): +class WritableConsoleServerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = ConsoleServerPortTemplate + fields = ['id', 'device_type', 'name'] + + +# +# Power port templates +# + +class PowerPortTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() class Meta: model = PowerPortTemplate - fields = ['id', 'name'] + fields = ['id', 'device_type', 'name'] -class PowerOutletTemplateNestedSerializer(serializers.ModelSerializer): +class WritablePowerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = PowerPortTemplate + fields = ['id', 'device_type', 'name'] + + +# +# Power outlet templates +# + +class PowerOutletTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() class Meta: model = PowerOutletTemplate - fields = ['id', 'name'] + fields = ['id', 'device_type', 'name'] -class InterfaceTemplateNestedSerializer(serializers.ModelSerializer): +class WritablePowerOutletTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = PowerOutletTemplate + fields = ['id', 'device_type', 'name'] + + +# +# Interface templates +# + +class InterfaceTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) class Meta: model = InterfaceTemplate - fields = ['id', 'name', 'form_factor', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] -class DeviceTypeDetailSerializer(DeviceTypeSerializer): - console_port_templates = ConsolePortTemplateNestedSerializer(many=True, read_only=True) - cs_port_templates = ConsoleServerPortTemplateNestedSerializer(many=True, read_only=True) - power_port_templates = PowerPortTemplateNestedSerializer(many=True, read_only=True) - power_outlet_templates = PowerPortTemplateNestedSerializer(many=True, read_only=True) - interface_templates = InterfaceTemplateNestedSerializer(many=True, read_only=True) +class WritableInterfaceTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): - class Meta(DeviceTypeSerializer.Meta): - fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'interface_ordering', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', - 'comments', 'custom_fields', 'console_port_templates', 'cs_port_templates', 'power_port_templates', - 'power_outlet_templates', 'interface_templates'] + class Meta: + model = InterfaceTemplate + fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] + + +# +# Device bay templates +# + +class DeviceBayTemplateSerializer(serializers.ModelSerializer): + device_type = NestedDeviceTypeSerializer() + + class Meta: + model = DeviceBayTemplate + fields = ['id', 'device_type', 'name'] + + +class WritableDeviceBayTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = DeviceBayTemplate + fields = ['id', 'device_type', 'name'] # # Device roles # -class DeviceRoleSerializer(serializers.ModelSerializer): +class DeviceRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceRole 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'] # # Platforms # -class PlatformSerializer(serializers.ModelSerializer): +class PlatformSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'rpc_client'] + fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client'] -class PlatformNestedSerializer(PlatformSerializer): +class NestedPlatformSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') - class Meta(PlatformSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = Platform + fields = ['id', 'url', 'name', 'slug'] # # Devices # -# Cannot import ipam.api.IPAddressNestedSerializer due to circular dependency -class DeviceIPAddressNestedSerializer(serializers.ModelSerializer): +# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency +class DeviceIPAddressSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') class Meta: model = IPAddress - fields = ['id', 'family', 'address'] + fields = ['id', 'url', 'family', 'address'] -class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): - device_type = DeviceTypeNestedSerializer() - device_role = DeviceRoleNestedSerializer() - tenant = TenantNestedSerializer() - platform = PlatformNestedSerializer() - site = SiteNestedSerializer() - rack = RackNestedSerializer() - primary_ip = DeviceIPAddressNestedSerializer() - primary_ip4 = DeviceIPAddressNestedSerializer() - primary_ip6 = DeviceIPAddressNestedSerializer() +class DeviceSerializer(CustomFieldModelSerializer): + device_type = NestedDeviceTypeSerializer() + device_role = NestedDeviceRoleSerializer() + tenant = NestedTenantSerializer() + platform = NestedPlatformSerializer() + site = NestedSiteSerializer() + rack = NestedRackSerializer() + face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES) + status = ChoiceFieldSerializer(choices=STATUS_CHOICES) + primary_ip = DeviceIPAddressSerializer() + primary_ip4 = DeviceIPAddressSerializer() + primary_ip6 = DeviceIPAddressSerializer() parent_device = serializers.SerializerMethodField() class Meta: @@ -314,21 +473,34 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): device_bay = obj.parent_bay except DeviceBay.DoesNotExist: return None - return { - 'id': device_bay.device.pk, - 'name': device_bay.device.name, - 'device_bay': { - 'id': device_bay.pk, - 'name': device_bay.name, - } - } + context = {'request': self.context['request']} + data = NestedDeviceSerializer(instance=device_bay.device, context=context).data + data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data + return data -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) + + # Enforce model validation + super(WritableDeviceSerializer, self).validate(data) + + return data # @@ -336,16 +508,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(ModelValidationMixin, serializers.ModelSerializer): - class Meta(ConsoleServerPortSerializer.Meta): + class Meta: + model = ConsoleServerPort fields = ['id', 'device', 'name'] @@ -354,18 +528,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(ModelValidationMixin, serializers.ModelSerializer): - class Meta(ConsolePortSerializer.Meta): - fields = ['id', 'device', 'name'] + class Meta: + model = ConsolePort + fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] # @@ -373,16 +548,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(ModelValidationMixin, serializers.ModelSerializer): - class Meta(PowerOutletSerializer.Meta): + class Meta: + model = PowerOutlet fields = ['id', 'device', 'name'] @@ -391,58 +568,108 @@ 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(ModelValidationMixin, 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 NestedInterfaceSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') class Meta: model = Interface - fields = ['id', 'name', 'form_factor'] + fields = ['id', 'url', 'name'] -class InterfaceSerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() - form_factor = serializers.ReadOnlyField(source='get_form_factor_display') - lag = LAGInterfaceNestedSerializer() +class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') class Meta: - model = Interface + model = Circuit + fields = ['id', 'url', 'cid'] + + +class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): + circuit = InterfaceNestedCircuitSerializer() + + class Meta: + model = CircuitTermination fields = [ - 'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected', + 'id', 'circuit', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', ] -class InterfaceNestedSerializer(InterfaceSerializer): - form_factor = serializers.ReadOnlyField(source='get_form_factor_display') +class InterfaceSerializer(serializers.ModelSerializer): + device = NestedDeviceSerializer() + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) + lag = NestedInterfaceSerializer() + is_connected = serializers.SerializerMethodField(read_only=True) + interface_connection = serializers.SerializerMethodField(read_only=True) + circuit_termination = InterfaceCircuitTerminationSerializer() - class Meta(InterfaceSerializer.Meta): - fields = ['id', 'device', 'name'] - - -class InterfaceDetailSerializer(InterfaceSerializer): - connected_interface = InterfaceSerializer() - - class Meta(InterfaceSerializer.Meta): + class Meta: + model = Interface fields = [ - 'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected', - 'connected_interface', + 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', + 'is_connected', 'interface_connection', 'circuit_termination', + ] + + def get_is_connected(self, obj): + """ + Return True if the interface has a connected interface or circuit termination. + """ + if obj.connection: + return True + try: + circuit_termination = obj.circuit_termination + return True + except CircuitTermination.DoesNotExist: + pass + return False + + def get_interface_connection(self, obj): + if obj.connection: + return OrderedDict(( + ('interface', PeerInterfaceSerializer(obj.connected_interface, context=self.context).data), + ('status', obj.connection.connection_status), + )) + return None + + +class PeerInterfaceSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + device = NestedDeviceSerializer() + form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) + lag = NestedInterfaceSerializer() + + class Meta: + model = Interface + fields = [ + 'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', + 'description', + ] + + +class WritableInterfaceSerializer(ModelValidationMixin, serializers.ModelSerializer): + + class Meta: + model = Interface + fields = [ + 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', ] @@ -451,44 +678,53 @@ class InterfaceDetailSerializer(InterfaceSerializer): # class DeviceBaySerializer(serializers.ModelSerializer): - device = DeviceNestedSerializer() + device = NestedDeviceSerializer() + installed_device = NestedDeviceSerializer() class Meta: model = DeviceBay - fields = ['id', 'device', 'name'] + fields = ['id', 'device', 'name', 'installed_device'] -class DeviceBayNestedSerializer(DeviceBaySerializer): - installed_device = DeviceNestedSerializer() +class NestedDeviceBaySerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') - class Meta(DeviceBaySerializer.Meta): - fields = ['id', 'name', 'installed_device'] + class Meta: + model = DeviceBay + fields = ['id', 'url', 'name'] -class DeviceBayDetailSerializer(DeviceBaySerializer): - installed_device = DeviceNestedSerializer() +class WritableDeviceBaySerializer(ModelValidationMixin, 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 - fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + model = InventoryItem + fields = [ + 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', + ] -class ModuleNestedSerializer(ModuleSerializer): +class WritableInventoryItemSerializer(ModelValidationMixin, serializers.ModelSerializer): - class Meta(ModuleSerializer.Meta): - fields = ['id', 'device', 'parent', 'name'] + class Meta: + model = InventoryItem + fields = [ + 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', + ] # @@ -496,6 +732,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(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = InterfaceConnection diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 0b9052d82..6f16310e5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,84 +1,64 @@ -from django.conf.urls import url +from __future__ import unicode_literals -from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE -from extras.api.views import GraphListView, TopologyMapView +from rest_framework import routers -from .views import * +from . import views -urlpatterns = [ +class DCIMRootView(routers.APIRootView): + """ + DCIM API root view + """ + def get_view_name(self): + return 'DCIM' - # Regions - url(r'^regions/$', RegionListView.as_view(), name='region_list'), - url(r'^regions/(?P\d+)/$', RegionDetailView.as_view(), name='region_detail'), - # Sites - url(r'^sites/$', SiteListView.as_view(), name='site_list'), - url(r'^sites/(?P\d+)/$', SiteDetailView.as_view(), name='site_detail'), - url(r'^sites/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'), - url(r'^sites/(?P\d+)/racks/$', RackListView.as_view(), name='site_racks'), +router = routers.DefaultRouter() +router.APIRootView = DCIMRootView - # Rack groups - url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'), - url(r'^rack-groups/(?P\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'), +# Sites +router.register(r'regions', views.RegionViewSet) +router.register(r'sites', views.SiteViewSet) - # Rack roles - url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'), - url(r'^rack-roles/(?P\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'), +# Racks +router.register(r'rack-groups', views.RackGroupViewSet) +router.register(r'rack-roles', views.RackRoleViewSet) +router.register(r'racks', views.RackViewSet) +router.register(r'rack-reservations', views.RackReservationViewSet) - # Racks - url(r'^racks/$', RackListView.as_view(), name='rack_list'), - url(r'^racks/(?P\d+)/$', RackDetailView.as_view(), name='rack_detail'), - url(r'^racks/(?P\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'), +# Device types +router.register(r'manufacturers', views.ManufacturerViewSet) +router.register(r'device-types', views.DeviceTypeViewSet) - # Rack reservations - url(r'^rack-reservations/$', RackReservationListView.as_view(), name='rackreservation_list'), - url(r'^rack-reservations/(?P\d+)/$', RackReservationDetailView.as_view(), name='rackreservation_detail'), +# Device type components +router.register(r'console-port-templates', views.ConsolePortTemplateViewSet) +router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet) +router.register(r'power-port-templates', views.PowerPortTemplateViewSet) +router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet) +router.register(r'interface-templates', views.InterfaceTemplateViewSet) +router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet) - # Manufacturers - url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'), - url(r'^manufacturers/(?P\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'), +# Devices +router.register(r'device-roles', views.DeviceRoleViewSet) +router.register(r'platforms', views.PlatformViewSet) +router.register(r'devices', views.DeviceViewSet) - # Device types - url(r'^device-types/$', DeviceTypeListView.as_view(), name='devicetype_list'), - url(r'^device-types/(?P\d+)/$', DeviceTypeDetailView.as_view(), name='devicetype_detail'), +# Device components +router.register(r'console-ports', views.ConsolePortViewSet) +router.register(r'console-server-ports', views.ConsoleServerPortViewSet) +router.register(r'power-ports', views.PowerPortViewSet) +router.register(r'power-outlets', views.PowerOutletViewSet) +router.register(r'interfaces', views.InterfaceViewSet) +router.register(r'device-bays', views.DeviceBayViewSet) +router.register(r'inventory-items', views.InventoryItemViewSet) - # Device roles - url(r'^device-roles/$', DeviceRoleListView.as_view(), name='devicerole_list'), - url(r'^device-roles/(?P\d+)/$', DeviceRoleDetailView.as_view(), name='devicerole_detail'), +# 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) - # Platforms - url(r'^platforms/$', PlatformListView.as_view(), name='platform_list'), - url(r'^platforms/(?P\d+)/$', PlatformDetailView.as_view(), name='platform_detail'), +# Miscellaneous +router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device') - # Devices - url(r'^devices/$', DeviceListView.as_view(), name='device_list'), - url(r'^devices/(?P\d+)/$', DeviceDetailView.as_view(), name='device_detail'), - url(r'^devices/(?P\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), - url(r'^devices/(?P\d+)/console-ports/$', ConsolePortListView.as_view(), name='device_consoleports'), - url(r'^devices/(?P\d+)/console-server-ports/$', ConsoleServerPortListView.as_view(), - name='device_consoleserverports'), - url(r'^devices/(?P\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'), - url(r'^devices/(?P\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'), - url(r'^devices/(?P\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'), - url(r'^devices/(?P\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'), - url(r'^devices/(?P\d+)/modules/$', ModuleListView.as_view(), name='device_modules'), - - # Console ports - url(r'^console-ports/(?P\d+)/$', ConsolePortView.as_view(), name='consoleport'), - - # Power ports - url(r'^power-ports/(?P\d+)/$', PowerPortView.as_view(), name='powerport'), - - # Interfaces - url(r'^interfaces/(?P\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'), - url(r'^interfaces/(?P\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\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection_detail'), - - # Miscellaneous - url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'), - url(r'^topology-maps/(?P[\w-]+)/$', TopologyMapView.as_view(), name='topology_map'), - -] +app_name = 'dcim-api' +urlpatterns = router.urls diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d1e721df0..d32c63bfa 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,23 +1,27 @@ -from rest_framework import generics -from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly +from __future__ import unicode_literals +from collections import OrderedDict + +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.http import HttpResponseBadRequest, HttpResponseForbidden 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,117 +30,70 @@ 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 + filter_class = filters.RegionFilter # # 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 + filter_class = filters.RackRoleFilter # # 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,398 +104,304 @@ 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 + filter_class = filters.ManufacturerFilter # -# 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 + filter_class = filters.DeviceRoleFilter # # 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 + filter_class = filters.PlatformFilter # # Devices # -class DeviceListView(CustomFieldModelAPIView, generics.ListAPIView): - """ - List devices (filterable) - """ +class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Device.objects.select_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay' + 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', ).prefetch_related( - 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'custom_field_values__field' + 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', ) serializer_class = serializers.DeviceSerializer + write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter - renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer, FlatJSONRenderer] - - -class DeviceDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): - """ - Retrieve a single device - """ - queryset = Device.objects.select_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay' - ).prefetch_related('custom_field_values__field') - serializer_class = serializers.DeviceSerializer - - -# -# Console ports -# - -class ConsolePortListView(generics.ListAPIView): - """ - List console ports (by device) - """ - serializer_class = serializers.ConsolePortSerializer - - def get_queryset(self): - - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return ConsolePort.objects.filter(device=device).select_related('cs_port') - - -class ConsolePortView(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [DjangoModelPermissionsOrAnonReadOnly] - serializer_class = serializers.ConsolePortSerializer - queryset = ConsolePort.objects.all() - - -# -# Console server ports -# - -class ConsoleServerPortListView(generics.ListAPIView): - """ - List console server ports (by device) - """ - serializer_class = serializers.ConsoleServerPortSerializer - - def get_queryset(self): - - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return ConsoleServerPort.objects.filter(device=device).select_related('connected_console') - - -# -# Power ports -# - -class PowerPortListView(generics.ListAPIView): - """ - List power ports (by device) - """ - serializer_class = serializers.PowerPortSerializer - - def get_queryset(self): - - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return PowerPort.objects.filter(device=device).select_related('power_outlet') - - -class PowerPortView(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [DjangoModelPermissionsOrAnonReadOnly] - serializer_class = serializers.PowerPortSerializer - queryset = PowerPort.objects.all() - - -# -# Power outlets -# - -class PowerOutletListView(generics.ListAPIView): - """ - List power outlets (by device) - """ - serializer_class = serializers.PowerOutletSerializer - - def get_queryset(self): - - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return PowerOutlet.objects.filter(device=device).select_related('connected_port') - - -# -# Interfaces -# - -class InterfaceListView(generics.ListAPIView): - """ - List interfaces (by device) - """ - serializer_class = serializers.InterfaceSerializer - filter_class = filters.InterfaceFilter - - def get_queryset(self): - - device = get_object_or_404(Device, pk=self.kwargs['pk']) - queryset = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\ - .select_related('connected_as_a', 'connected_as_b', 'circuit_termination') - - # Filter by type (physical or virtual) - iface_type = self.request.query_params.get('type') - if iface_type == 'physical': - queryset = queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES) - elif iface_type == 'virtual': - queryset = queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES) - elif iface_type is not None: - queryset = queryset.empty() - - return queryset - - -class InterfaceDetailView(generics.RetrieveAPIView): - """ - Retrieve a single interface - """ - queryset = Interface.objects.select_related('device') - serializer_class = serializers.InterfaceDetailSerializer - - -class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView): - permission_classes = [DjangoModelPermissionsOrAnonReadOnly] - serializer_class = serializers.InterfaceConnectionSerializer - queryset = InterfaceConnection.objects.all() - - -class InterfaceConnectionListView(generics.ListAPIView): - """ - Retrieve a list of all interface connections - """ - serializer_class = serializers.InterfaceConnectionSerializer - queryset = InterfaceConnection.objects.all() - - -# -# Device bays -# - -class DeviceBayListView(generics.ListAPIView): - """ - List device bays (by device) - """ - serializer_class = serializers.DeviceBayNestedSerializer - - def get_queryset(self): - - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return DeviceBay.objects.filter(device=device).select_related('installed_device') - - -# -# Modules -# - -class ModuleListView(generics.ListAPIView): - """ - List device modules (by device) - """ - serializer_class = serializers.ModuleSerializer - - def get_queryset(self): - - device = get_object_or_404(Device, pk=self.kwargs['pk']) - return Module.objects.filter(device=device).select_related('device', 'manufacturer') - - -# -# Live queries -# - -class LLDPNeighborsView(APIView): - """ - Retrieve live LLDP neighbors of a device - """ - - def get(self, request, pk): + @detail_route(url_path='napalm') + def napalm(self, request, pk): + """ + Execute a NAPALM method on a Device + """ device = get_object_or_404(Device, pk=pk) if not device.primary_ip: - raise ServiceUnavailable(detail="No IP configured for this device.") + raise ServiceUnavailable("This device does not have a primary IP address configured.") + if device.platform is None: + raise ServiceUnavailable("No platform is configured for this device.") + if not device.platform.napalm_driver: + raise ServiceUnavailable("No NAPALM driver is configured for this device's platform ().".format( + device.platform + )) - RPC = device.get_rpc_client() - if not RPC: - raise ServiceUnavailable(detail="No RPC client available for this platform ({}).".format(device.platform)) - - # Connect to device and retrieve inventory info + # Check that NAPALM is installed and verify the configured driver 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.") + import napalm + from napalm_base.exceptions import ConnectAuthError, ModuleImportError + except ImportError: + raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") + try: + driver = napalm.get_network_driver(device.platform.napalm_driver) + except ModuleImportError: + raise ServiceUnavailable("NAPALM driver for platform {} not found: {}.".format( + device.platform, device.platform.napalm_driver + )) - return Response(lldp_neighbors) + # Verify user permission + if not request.user.has_perm('dcim.napalm_read'): + return HttpResponseForbidden() + + # Validate requested NAPALM methods + napalm_methods = request.GET.getlist('method') + for method in napalm_methods: + if not hasattr(driver, method): + return HttpResponseBadRequest("Unknown NAPALM method: {}".format(method)) + elif not method.startswith('get_'): + return HttpResponseBadRequest("Unsupported NAPALM method: {}".format(method)) + + # Connect to the device and execute the requested methods + # TODO: Improve error handling + response = OrderedDict([(m, None) for m in napalm_methods]) + ip_address = str(device.primary_ip.address.ip) + d = driver( + hostname=ip_address, + username=settings.NETBOX_USERNAME, + password=settings.NETBOX_PASSWORD + ) + try: + d.open() + for method in napalm_methods: + response[method] = getattr(d, method)() + except Exception as e: + raise ServiceUnavailable("Error connecting to the device: {}".format(e)) + + d.close() + return Response(response) + + +# +# Device components +# + +class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ConsolePort.objects.select_related('device', 'cs_port__device') + serializer_class = serializers.ConsolePortSerializer + write_serializer_class = serializers.WritableConsolePortSerializer + filter_class = filters.ConsolePortFilter + + +class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') + serializer_class = serializers.ConsoleServerPortSerializer + write_serializer_class = serializers.WritableConsoleServerPortSerializer + filter_class = filters.ConsoleServerPortFilter + + +class PowerPortViewSet(WritableSerializerMixin, ModelViewSet): + queryset = PowerPort.objects.select_related('device', 'power_outlet__device') + serializer_class = serializers.PowerPortSerializer + write_serializer_class = serializers.WritablePowerPortSerializer + filter_class = filters.PowerPortFilter + + +class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet): + queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') + serializer_class = serializers.PowerOutletSerializer + write_serializer_class = serializers.WritablePowerOutletSerializer + filter_class = filters.PowerOutletFilter + + +class InterfaceViewSet(WritableSerializerMixin, ModelViewSet): + queryset = Interface.objects.select_related('device') + serializer_class = serializers.InterfaceSerializer + write_serializer_class = serializers.WritableInterfaceSerializer + filter_class = filters.InterfaceFilter + + @detail_route() + def graphs(self, request, pk=None): + """ + A convenience method for rendering graphs for a particular interface. + """ + interface = get_object_or_404(Interface, pk=pk) + queryset = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE) + serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface}) + return Response(serializer.data) + + +class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet): + queryset = DeviceBay.objects.select_related('installed_device') + serializer_class = serializers.DeviceBaySerializer + write_serializer_class = serializers.WritableDeviceBaySerializer + filter_class = filters.DeviceBayFilter + + +class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet): + queryset = InventoryItem.objects.select_related('device', 'manufacturer') + serializer_class = serializers.InventoryItemSerializer + write_serializer_class = serializers.WritableInventoryItemSerializer + filter_class = filters.InventoryItemFilter + + +# +# Connections +# + +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 + + +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 + + +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) diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index fdfcc1f57..fb1f4ee39 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django.apps import AppConfig diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py new file mode 100644 index 000000000..f2c047910 --- /dev/null +++ b/netbox/dcim/constants.py @@ -0,0 +1,230 @@ +from __future__ import unicode_literals + + +# Rack types +RACK_TYPE_2POST = 100 +RACK_TYPE_4POST = 200 +RACK_TYPE_CABINET = 300 +RACK_TYPE_WALLFRAME = 1000 +RACK_TYPE_WALLCABINET = 1100 +RACK_TYPE_CHOICES = ( + (RACK_TYPE_2POST, '2-post frame'), + (RACK_TYPE_4POST, '4-post frame'), + (RACK_TYPE_CABINET, '4-post cabinet'), + (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'), + (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'), +) + +# Rack widths +RACK_WIDTH_19IN = 19 +RACK_WIDTH_23IN = 23 +RACK_WIDTH_CHOICES = ( + (RACK_WIDTH_19IN, '19 inches'), + (RACK_WIDTH_23IN, '23 inches'), +) + +# Rack faces +RACK_FACE_FRONT = 0 +RACK_FACE_REAR = 1 +RACK_FACE_CHOICES = [ + [RACK_FACE_FRONT, 'Front'], + [RACK_FACE_REAR, 'Rear'], +] + +# Parent/child device roles +SUBDEVICE_ROLE_PARENT = True +SUBDEVICE_ROLE_CHILD = False +SUBDEVICE_ROLE_CHOICES = ( + (None, 'None'), + (SUBDEVICE_ROLE_PARENT, 'Parent'), + (SUBDEVICE_ROLE_CHILD, 'Child'), +) + +# Interface ordering schemes (for device types) +IFACE_ORDERING_POSITION = 1 +IFACE_ORDERING_NAME = 2 +IFACE_ORDERING_CHOICES = [ + [IFACE_ORDERING_POSITION, 'Slot/position'], + [IFACE_ORDERING_NAME, 'Name (alphabetically)'] +] + +# Interface form factors +# Virtual +IFACE_FF_VIRTUAL = 0 +IFACE_FF_LAG = 200 +# Ethernet +IFACE_FF_100ME_FIXED = 800 +IFACE_FF_1GE_FIXED = 1000 +IFACE_FF_1GE_GBIC = 1050 +IFACE_FF_1GE_SFP = 1100 +IFACE_FF_10GE_FIXED = 1150 +IFACE_FF_10GE_SFP_PLUS = 1200 +IFACE_FF_10GE_XFP = 1300 +IFACE_FF_10GE_XENPAK = 1310 +IFACE_FF_10GE_X2 = 1320 +IFACE_FF_25GE_SFP28 = 1350 +IFACE_FF_40GE_QSFP_PLUS = 1400 +IFACE_FF_100GE_CFP = 1500 +IFACE_FF_100GE_QSFP28 = 1600 +# Wireless +IFACE_FF_80211A = 2600 +IFACE_FF_80211G = 2610 +IFACE_FF_80211N = 2620 +IFACE_FF_80211AC = 2630 +IFACE_FF_80211AD = 2640 +# Fibrechannel +IFACE_FF_1GFC_SFP = 3010 +IFACE_FF_2GFC_SFP = 3020 +IFACE_FF_4GFC_SFP = 3040 +IFACE_FF_8GFC_SFP_PLUS = 3080 +IFACE_FF_16GFC_SFP_PLUS = 3160 +# Serial +IFACE_FF_T1 = 4000 +IFACE_FF_E1 = 4010 +IFACE_FF_T3 = 4040 +IFACE_FF_E3 = 4050 +# Stacking +IFACE_FF_STACKWISE = 5000 +IFACE_FF_STACKWISE_PLUS = 5050 +IFACE_FF_FLEXSTACK = 5100 +IFACE_FF_FLEXSTACK_PLUS = 5150 +IFACE_FF_JUNIPER_VCP = 5200 +# Other +IFACE_FF_OTHER = 32767 + +IFACE_FF_CHOICES = [ + [ + 'Virtual interfaces', + [ + [IFACE_FF_VIRTUAL, 'Virtual'], + [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], + ] + ], + [ + 'Ethernet (fixed)', + [ + [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'], + [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'], + [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'], + ] + ], + [ + 'Ethernet (modular)', + [ + [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'], + [IFACE_FF_1GE_SFP, 'SFP (1GE)'], + [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'], + [IFACE_FF_10GE_XFP, 'XFP (10GE)'], + [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'], + [IFACE_FF_10GE_X2, 'X2 (10GE)'], + [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], + [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], + [IFACE_FF_100GE_CFP, 'CFP (100GE)'], + [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], + ] + ], + [ + 'Wireless', + [ + [IFACE_FF_80211A, 'IEEE 802.11a'], + [IFACE_FF_80211G, 'IEEE 802.11b/g'], + [IFACE_FF_80211N, 'IEEE 802.11n'], + [IFACE_FF_80211AC, 'IEEE 802.11ac'], + [IFACE_FF_80211AD, 'IEEE 802.11ad'], + ] + ], + [ + 'FibreChannel', + [ + [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'], + [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'], + [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'], + [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], + [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], + ] + ], + [ + 'Serial', + [ + [IFACE_FF_T1, 'T1 (1.544 Mbps)'], + [IFACE_FF_E1, 'E1 (2.048 Mbps)'], + [IFACE_FF_T3, 'T3 (45 Mbps)'], + [IFACE_FF_E3, 'E3 (34 Mbps)'], + ] + ], + [ + 'Stacking', + [ + [IFACE_FF_STACKWISE, 'Cisco StackWise'], + [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], + [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], + [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], + [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'], + ] + ], + [ + 'Other', + [ + [IFACE_FF_OTHER, 'Other'], + ] + ], +] + +VIRTUAL_IFACE_TYPES = [ + IFACE_FF_VIRTUAL, + IFACE_FF_LAG, +] + +WIRELESS_IFACE_TYPES = [ + IFACE_FF_80211A, + IFACE_FF_80211G, + IFACE_FF_80211N, + IFACE_FF_80211AC, + IFACE_FF_80211AD, +] + +NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES + +# Device statuses +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'], +] + +# Bootstrap CSS classes for device stasuses +DEVICE_STATUS_CLASSES = { + 0: 'warning', + 1: 'success', + 2: 'info', + 3: 'primary', + 4: 'danger', + 5: 'default', +} + +# Console/power/interface connection statuses +CONNECTION_STATUS_PLANNED = False +CONNECTION_STATUS_CONNECTED = True +CONNECTION_STATUS_CHOICES = [ + [CONNECTION_STATUS_PLANNED, 'Planned'], + [CONNECTION_STATUS_CONNECTED, 'Connected'], +] + +# Platform -> RPC client mappings +RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos' +RPC_CLIENT_CISCO_IOS = 'cisco-ios' +RPC_CLIENT_OPENGEAR = 'opengear' +RPC_CLIENT_CHOICES = [ + [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'], + [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'], + [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'], +] diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 6b45f6e65..22e0be581 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from netaddr import EUI, mac_unix_expanded from django.core.exceptions import ValidationError diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index bf390e17b..e3579085a 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,18 +1,39 @@ +from __future__ import unicode_literals + import django_filters from netaddr.core import AddrFormatError +from django.contrib.auth.models import User from django.db.models import Q 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, NONCONNECTABLE_IFACE_TYPES, Platform, PowerOutlet, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, + VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES, ) +class RegionFilter(django_filters.FilterSet): + parent_id = NullableModelMultipleChoiceFilter( + queryset=Region.objects.all(), + label='Parent region (ID)', + ) + parent = NullableModelMultipleChoiceFilter( + queryset=Region.objects.all(), + to_field_name='slug', + label='Parent region (slug)', + ) + + class Meta: + model = Region + fields = ['name', 'slug'] + + class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -20,23 +41,19 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) region_id = NullableModelMultipleChoiceFilter( - name='region', queryset=Region.objects.all(), label='Region (ID)', ) region = NullableModelMultipleChoiceFilter( - name='region', queryset=Region.objects.all(), to_field_name='slug', label='Region (slug)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) tenant = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -44,7 +61,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Site - fields = ['q', 'name', 'facility', 'asn'] + fields = ['q', 'name', 'slug', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email'] def search(self, queryset, name, value): if not value.strip(): @@ -54,6 +71,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(facility__icontains=value) | Q(physical_address__icontains=value) | Q(shipping_address__icontains=value) | + Q(contact_name__icontains=value) | + Q(contact_phone__icontains=value) | + Q(contact_email__icontains=value) | Q(comments__icontains=value) ) try: @@ -65,7 +85,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): class RackGroupFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -78,7 +97,14 @@ class RackGroupFilter(django_filters.FilterSet): class Meta: model = RackGroup - fields = ['name'] + fields = ['site_id', 'name', 'slug'] + + +class RackRoleFilter(django_filters.FilterSet): + + class Meta: + model = RackRole + fields = ['name', 'slug', 'color'] class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -88,7 +114,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -99,7 +124,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (slug)', ) group_id = NullableModelMultipleChoiceFilter( - name='group', queryset=RackGroup.objects.all(), label='Group (ID)', ) @@ -110,7 +134,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -121,7 +144,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) role_id = NullableModelMultipleChoiceFilter( - name='role', queryset=RackRole.objects.all(), label='Role (ID)', ) @@ -134,7 +156,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Rack - fields = ['u_height'] + fields = ['facility_id', 'type', 'width', 'u_height', 'desc_units'] def search(self, queryset, name, value): if not value.strip(): @@ -147,15 +169,68 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): class RackReservationFilter(django_filters.FilterSet): + id__in = NumericInFilter(name='id', lookup_expr='in') + q = django_filters.CharFilter( + method='search', + label='Search', + ) rack_id = django_filters.ModelMultipleChoiceFilter( - name='rack', queryset=Rack.objects.all(), label='Rack (ID)', ) + site_id = django_filters.ModelMultipleChoiceFilter( + name='rack__site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='rack__site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + group_id = NullableModelMultipleChoiceFilter( + name='rack__group', + queryset=RackGroup.objects.all(), + label='Group (ID)', + ) + group = NullableModelMultipleChoiceFilter( + name='rack__group', + queryset=RackGroup.objects.all(), + to_field_name='slug', + label='Group', + ) + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label='User (ID)', + ) + user = django_filters.ModelMultipleChoiceFilter( + name='user', + queryset=User.objects.all(), + to_field_name='username', + label='User (name)', + ) class Meta: model = RackReservation - fields = ['rack', 'user'] + fields = ['created'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(rack__name__icontains=value) | + Q(rack__facility_id__icontains=value) | + Q(user__username__icontains=value) | + Q(description__icontains=value) + ) + + +class ManufacturerFilter(django_filters.FilterSet): + + class Meta: + model = Manufacturer + fields = ['name', 'slug'] class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -165,7 +240,6 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='manufacturer', queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', ) @@ -179,7 +253,8 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = DeviceType fields = [ - 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', + 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', ] def search(self, queryset, name, value): @@ -193,18 +268,122 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): ) +class DeviceTypeComponentFilterSet(django_filters.FilterSet): + devicetype_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceType.objects.all(), + label='Device type (ID)', + ) + + +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', 'mgmt_only'] + + +class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): + + class Meta: + model = DeviceBayTemplate + fields = ['name'] + + +class DeviceRoleFilter(django_filters.FilterSet): + + class Meta: + model = DeviceRole + fields = ['name', 'slug', 'color'] + + +class PlatformFilter(django_filters.FilterSet): + + class Meta: + model = Platform + fields = ['name', 'slug'] + + class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', ) - mac_address = django_filters.CharFilter( - method='_mac_address', - label='MAC address', + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + name='device_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + name='device_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceType.objects.all(), + label='Device type (ID)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + name='device_role_id', + queryset=DeviceRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + name='device_role__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + tenant_id = NullableModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label='Tenant (ID)', + ) + tenant = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', + ) + platform_id = NullableModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label='Platform (ID)', + ) + platform = NullableModelMultipleChoiceFilter( + name='platform', + queryset=Platform.objects.all(), + to_field_name='slug', + label='Platform (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -224,64 +403,18 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=Rack.objects.all(), label='Rack (ID)', ) - role_id = django_filters.ModelMultipleChoiceFilter( - name='device_role', - queryset=DeviceRole.objects.all(), - label='Role (ID)', - ) - role = django_filters.ModelMultipleChoiceFilter( - name='device_role__slug', - queryset=DeviceRole.objects.all(), - to_field_name='slug', - label='Role (slug)', - ) - tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = NullableModelMultipleChoiceFilter( - name='tenant', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) - device_type_id = django_filters.ModelMultipleChoiceFilter( - name='device_type', - queryset=DeviceType.objects.all(), - label='Device type (ID)', - ) - manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer', - queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', - ) - manufacturer = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer__slug', - queryset=Manufacturer.objects.all(), - to_field_name='slug', - label='Manufacturer (slug)', - ) model = django_filters.ModelMultipleChoiceFilter( name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', label='Device model (slug)', ) - platform_id = NullableModelMultipleChoiceFilter( - name='platform', - queryset=Platform.objects.all(), - label='Platform (ID)', + status = django_filters.MultipleChoiceFilter( + choices=STATUS_CHOICES ) - platform = NullableModelMultipleChoiceFilter( - name='platform', - queryset=Platform.objects.all(), - to_field_name='slug', - label='Platform (slug)', - ) - status = django_filters.BooleanFilter( - name='status', - label='Status', + is_full_depth = django_filters.BooleanFilter( + name='device_type__is_full_depth', + label='Is full depth', ) is_console_server = django_filters.BooleanFilter( name='device_type__is_console_server', @@ -295,6 +428,14 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): name='device_type__is_network_device', label='Is a network device', ) + mac_address = django_filters.CharFilter( + method='_mac_address', + label='MAC address', + ) + has_primary_ip = django_filters.BooleanFilter( + method='_has_primary_ip', + label='Has a primary IP', + ) class Meta: model = Device @@ -306,7 +447,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() @@ -320,73 +461,53 @@ 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): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', + +class DeviceComponentFilterSet(django_filters.FilterSet): + device_id = django_filters.ModelChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', ) - device = django_filters.ModelMultipleChoiceFilter( - name='device', + device = django_filters.ModelChoiceFilter( 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 @@ -394,21 +515,29 @@ class PowerOutletFilter(django_filters.FilterSet): class InterfaceFilter(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - label='Device (ID)', + """ + Not using DeviceComponentFilterSet for Interfaces because we need to glean the ordering logic from the parent + Device's DeviceType. + """ + device = django_filters.CharFilter( + method='filter_device', + name='name', + label='Device', ) - device = django_filters.ModelMultipleChoiceFilter( - name='device', - queryset=Device.objects.all(), - to_field_name='name', - label='Device (name)', + device_id = django_filters.NumberFilter( + method='filter_device', + name='pk', + label='Device (ID)', ) type = django_filters.CharFilter( method='filter_type', label='Interface type', ) + lag_id = django_filters.ModelMultipleChoiceFilter( + name='lag', + queryset=Interface.objects.all(), + label='LAG interface (ID)', + ) mac_address = django_filters.CharFilter( method='_mac_address', label='MAC address', @@ -416,17 +545,24 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name'] + fields = ['name', 'form_factor', 'enabled', 'mtu', 'mgmt_only'] + + def filter_device(self, queryset, name, value): + try: + device = Device.objects.select_related('device_type').get(**{name: value}) + ordering = device.device_type.interface_ordering + return queryset.filter(device=device).order_naturally(ordering) + except Device.DoesNotExist: + return queryset.none() def filter_type(self, queryset, name, value): value = value.strip().lower() - if value == 'physical': - return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES) - elif value == 'virtual': - return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES) - elif value == 'lag': - return queryset.filter(form_factor=IFACE_FF_LAG) - return queryset + return { + 'physical': queryset.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES), + 'virtual': queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES), + 'wireless': queryset.filter(form_factor__in=WIRELESS_IFACE_TYPES), + 'lag': queryset.filter(form_factor=IFACE_FF_LAG), + }.get(value, queryset.none()) def _mac_address(self, queryset, name, value): value = value.strip() @@ -438,6 +574,34 @@ class InterfaceFilter(django_filters.FilterSet): return queryset.none() +class DeviceBayFilter(DeviceComponentFilterSet): + + class Meta: + model = DeviceBay + fields = ['name'] + + +class InventoryItemFilter(DeviceComponentFilterSet): + parent_id = NullableModelMultipleChoiceFilter( + queryset=InventoryItem.objects.all(), + label='Parent inventory item (ID)', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + + class Meta: + model = InventoryItem + fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered'] + + class ConsoleConnectionFilter(django_filters.FilterSet): site = django_filters.CharFilter( method='filter_site', @@ -448,6 +612,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 @@ -472,6 +640,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 @@ -496,6 +668,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 diff --git a/netbox/dcim/formfields.py b/netbox/dcim/formfields.py index 4e568c2e6..83054c088 100644 --- a/netbox/dcim/formfields.py +++ b/netbox/dcim/formfields.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from netaddr import EUI, AddrFormatError from django import forms diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e2cd829b8..440c12623 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,29 +1,30 @@ -import re +from __future__ import unicode_literals from mptt.forms import TreeNodeChoiceField +import re from django import forms from django.contrib.postgres.forms.array import SimpleArrayField -from django.core.exceptions import ValidationError from django.db.models import Count, Q from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress +from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, - CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, - SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField, + APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField, + FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + FilterTreeNodeMultipleChoiceField, ) - from .formfields import MACAddressFormField 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 + DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort, + ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, + IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, + InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES, + RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN, + Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) @@ -48,14 +49,6 @@ def get_device_by_name_or_pk(name): return device -def validate_connection_status(value): - """ - Custom validator for connection statuses. value must be either "planned" or "connected" (case-insensitive). - """ - if value.lower() not in ['planned', 'connected']: - raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value)) - - class DeviceComponentForm(BootstrapMixin, forms.Form): """ Allow inclusion of the parent device as context for limiting field choices. @@ -81,7 +74,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): # Sites # -class SiteForm(BootstrapMixin, CustomFieldForm): +class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False) slug = SlugField() comments = CommentField() @@ -89,8 +82,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm): class Meta: model = Site fields = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', - 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address', + 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), @@ -105,27 +98,37 @@ class SiteForm(BootstrapMixin, CustomFieldForm): } -class SiteFromCSVForm(forms.ModelForm): +class SiteCSVForm(forms.ModelForm): region = forms.ModelChoiceField( - Region.objects.all(), to_field_name='name', required=False, error_messages={ - 'invalid_choice': 'Tenant not found.' + queryset=Region.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned region', + error_messages={ + 'invalid_choice': 'Region not found.', } ) tenant = forms.ModelChoiceField( - Tenant.objects.all(), to_field_name='name', required=False, error_messages={ - 'invalid_choice': 'Tenant not found.' + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', } ) class Meta: model = Site fields = [ - 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', ] - - -class SiteImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=SiteFromCSVForm) + help_texts = { + 'name': 'Site name', + 'slug': 'URL-friendly slug', + 'asn': '32-bit autonomous system number', + } class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -185,16 +188,25 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): # Racks # -class RackForm(BootstrapMixin, CustomFieldForm): - group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect( - api_url='/api/dcim/rack-groups/?site_id={{site}}', - )) +class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm): + group = ChainedModelChoiceField( + queryset=RackGroup.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-groups/?site_id={{site}}', + ) + ) comments = CommentField() class Meta: model = Rack - fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', - 'comments'] + fields = [ + 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height', + 'desc_units', 'comments', + ] help_texts = { 'site': "The site at which the rack exists", 'name': "Organizational rack name", @@ -205,62 +217,74 @@ class RackForm(BootstrapMixin, CustomFieldForm): 'site': forms.Select(attrs={'filter-for': 'group'}), } - def __init__(self, *args, **kwargs): - super(RackForm, self).__init__(*args, **kwargs) - - # Limit rack group choices - if self.is_bound and self.data.get('site'): - self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site']) - else: - self.fields['group'].choices = [] - - -class RackFromCSVForm(forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'}) - group_name = forms.CharField(required=False) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - role = forms.ModelChoiceField(RackRole.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Role not found.'}) - type = forms.CharField(required=False) +class RackCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + group_name = forms.CharField( + help_text='Name of rack group', + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + role = forms.ModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned role', + error_messages={ + 'invalid_choice': 'Role not found.', + } + ) + type = CSVChoiceField( + choices=RACK_TYPE_CHOICES, + required=False, + help_text='Rack type' + ) + width = forms.ChoiceField( + choices=( + (RACK_WIDTH_19IN, '19'), + (RACK_WIDTH_23IN, '23'), + ), + help_text='Rail-to-rail width (in inches)' + ) class Meta: model = Rack - fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', - 'desc_units'] + fields = [ + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + ] + help_texts = { + 'name': 'Rack name', + 'u_height': 'Height in rack units', + } def clean(self): + super(RackCSVForm, self).clean() + site = self.cleaned_data.get('site') - group = self.cleaned_data.get('group_name') + group_name = self.cleaned_data.get('group_name') # Validate rack group - if site and group: + if group_name: try: - self.instance.group = RackGroup.objects.get(site=site, name=group) + self.instance.group = RackGroup.objects.get(site=site, name=group_name) except RackGroup.DoesNotExist: - self.add_error('group_name', "Invalid rack group ({})".format(group)) - - def clean_type(self): - rack_type = self.cleaned_data['type'] - if not rack_type: - return None - try: - choices = {v.lower(): k for k, v in RACK_TYPE_CHOICES} - return choices[rack_type.lower()] - except KeyError: - raise forms.ValidationError('Invalid rack type ({}). Valid choices are: {}.'.format( - rack_type, - ', '.join({v: k for k, v in RACK_TYPE_CHOICES}), - )) - - -class RackImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=RackFromCSVForm) + raise forms.ValidationError("Rack group {} not found for site {}".format(group_name, site)) class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -272,6 +296,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type') width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width') u_height = forms.IntegerField(required=False, label='Height (U)') + desc_units = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Descending units') comments = CommentField(widget=SmallTextarea) class Meta: @@ -330,6 +355,19 @@ class RackReservationForm(BootstrapMixin, forms.ModelForm): return unit_choices +class RackReservationFilterForm(BootstrapMixin, forms.Form): + q = forms.CharField(required=False, label='Search') + site = FilterChoiceField( + queryset=Site.objects.annotate(filter_count=Count('racks__reservations')), + to_field_name='slug' + ) + group_id = FilterChoiceField( + queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')), + label='Rack group', + null_option=(0, 'None') + ) + + # # Manufacturers # @@ -362,7 +400,15 @@ class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) u_height = forms.IntegerField(min_value=1, required=False) + is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is full depth') interface_ordering = forms.ChoiceField(choices=add_blank_choice(IFACE_ORDERING_CHOICES), required=False) + is_console_server = forms.NullBooleanField( + required=False, widget=BulkEditNullBooleanSelect, label='Is a console server' + ) + is_pdu = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a PDU') + is_network_device = forms.NullBooleanField( + required=False, widget=BulkEditNullBooleanSelect, label='Is a network device' + ) class Meta: nullable_fields = [] @@ -471,6 +517,7 @@ class InterfaceTemplateCreateForm(DeviceComponentForm): class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput) form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) + mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') class Meta: nullable_fields = [] @@ -511,63 +558,96 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug'] + fields = ['name', 'slug', 'napalm_driver', 'rpc_client'] # # Devices # -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' - )) +class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), + 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 = ChainedModelChoiceField( + queryset=DeviceType.objects.all(), + chains=( + ('manufacturer', 'manufacturer'), + ), + 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', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status', + 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', + ] help_texts = { 'device_role': "The function this device serves", 'serial': "Chassis serial number", } widgets = { 'face': forms.Select(attrs={'filter-for': 'position'}), - 'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}), } def __init__(self, *args, **kwargs): + # Initialize helper selectors + instance = kwargs.get('instance') + # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field + if instance and hasattr(instance, 'device_type'): + initial = kwargs.get('initial', {}).copy() + initial['manufacturer'] = instance.device_type.manufacturer + kwargs['initial'] = initial + super(DeviceForm, self).__init__(*args, **kwargs) if self.instance.pk: - # Initialize helper selections - self.initial['site'] = self.instance.site - self.initial['manufacturer'] = self.instance.device_type.manufacturer - # Compile list of choices for primary IPv4 and IPv6 addresses for family in [4, 6]: ip_choices = [] interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance) - ip_choices += [(ip.id, u'{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] + ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\ .select_related('nat_inside__interface') - ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips] + ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips] self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device @@ -582,14 +662,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): self.fields['primary_ip6'].choices = [] self.fields['primary_ip6'].widget.attrs['readonly'] = True - # Limit rack choices - if self.is_bound and self.data.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - # Rack position pk = self.instance.pk if self.instance.pk else None try: @@ -610,16 +682,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): }) for p in position_choices ] - # Limit device_type choices - if self.is_bound: - self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\ - .select_related('manufacturer') - elif self.initial.get('manufacturer'): - self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\ - .select_related('manufacturer') - else: - self.fields['device_type'].choices = [] - # Disable rack assignment if this is a child device installed in a parent device if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): self.fields['site'].disabled = True @@ -628,23 +690,60 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): self.initial['rack'] = self.instance.parent_bay.device.rack_id -class BaseDeviceFromCSVForm(forms.ModelForm): - device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid device role.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid manufacturer.'}) - model_name = forms.CharField() - platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Invalid platform.'}) +class BaseDeviceCSVForm(forms.ModelForm): + device_role = forms.ModelChoiceField( + queryset=DeviceRole.objects.all(), + to_field_name='name', + help_text='Name of assigned role', + error_messages={ + 'invalid_choice': 'Invalid device role.', + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + help_text='Device type manufacturer', + error_messages={ + 'invalid_choice': 'Invalid manufacturer.', + } + ) + model_name = forms.CharField( + help_text='Device type model name' + ) + platform = forms.ModelChoiceField( + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned platform', + error_messages={ + 'invalid_choice': 'Invalid platform.', + } + ) + status = CSVChoiceField( + choices=STATUS_CHOICES, + help_text='Operational status of device' + ) class Meta: fields = [] model = Device + help_texts = { + 'name': 'Device name', + } def clean(self): + super(BaseDeviceCSVForm, self).clean() + manufacturer = self.cleaned_data.get('manufacturer') model_name = self.cleaned_data.get('model_name') @@ -653,67 +752,81 @@ class BaseDeviceFromCSVForm(forms.ModelForm): try: self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name) except DeviceType.DoesNotExist: - self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name)) + raise forms.ValidationError("Device type {} {} not found".format(manufacturer, model_name)) -class DeviceFromCSVForm(BaseDeviceFromCSVForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={ - 'invalid_choice': 'Invalid site name.', - }) - rack_name = forms.CharField(required=False) - face = forms.CharField(required=False) - - class Meta(BaseDeviceFromCSVForm.Meta): - fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', - 'site', 'rack_name', 'position', 'face'] - - def clean(self): - - super(DeviceFromCSVForm, self).clean() - - site = self.cleaned_data.get('site') - rack_name = self.cleaned_data.get('rack_name') - - # Validate rack - if site and rack_name: - try: - self.instance.rack = Rack.objects.get(site=site, name=rack_name) - except Rack.DoesNotExist: - self.add_error('rack_name', "Invalid rack ({})".format(rack_name)) - - def clean_face(self): - face = self.cleaned_data['face'] - if not face: - return None - try: - return { - 'front': 0, - 'rear': 1, - }[face.lower()] - except KeyError: - raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face)) - - -class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): - parent = FlexibleModelChoiceField( - queryset=Device.objects.all(), +class DeviceCSVForm(BaseDeviceCSVForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), to_field_name='name', - required=False, + help_text='Name of parent site', error_messages={ - 'invalid_choice': 'Parent device not found.' + 'invalid_choice': 'Invalid site name.', } ) - device_bay_name = forms.CharField(required=False) + rack_group = forms.CharField( + required=False, + help_text='Parent rack\'s group (if any)' + ) + rack_name = forms.CharField( + required=False, + help_text='Name of parent rack' + ) + face = CSVChoiceField( + choices=RACK_FACE_CHOICES, + required=False, + help_text='Mounted rack face' + ) - class Meta(BaseDeviceFromCSVForm.Meta): + class Meta(BaseDeviceCSVForm.Meta): fields = [ - 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'parent', - 'device_bay_name', + 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', + 'site', 'rack_group', 'rack_name', 'position', 'face', ] def clean(self): - super(ChildDeviceFromCSVForm, self).clean() + super(DeviceCSVForm, self).clean() + + site = self.cleaned_data.get('site') + rack_group = self.cleaned_data.get('rack_group') + rack_name = self.cleaned_data.get('rack_name') + + # Validate rack + if site and rack_group and rack_name: + try: + self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name) + except Rack.DoesNotExist: + raise forms.ValidationError("Rack {} not found in site {} group {}".format(rack_name, site, rack_group)) + elif site and rack_name: + try: + self.instance.rack = Rack.objects.get(site=site, group__isnull=True, name=rack_name) + except Rack.DoesNotExist: + raise forms.ValidationError("Rack {} not found in site {} (no group)".format(rack_name, site)) + + +class ChildDeviceCSVForm(BaseDeviceCSVForm): + parent = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Name or ID of parent device', + error_messages={ + 'invalid_choice': 'Parent device not found.', + } + ) + device_bay_name = forms.CharField( + help_text='Name of device bay', + ) + + class Meta(BaseDeviceCSVForm.Meta): + fields = [ + 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', + 'parent', 'device_bay_name', + ] + + def clean(self): + + super(ChildDeviceCSVForm, self).clean() parent = self.cleaned_data.get('parent') device_bay_name = self.cleaned_data.get('device_bay_name') @@ -721,22 +834,12 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): # Validate device bay if parent and device_bay_name: try: - device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name) - if device_bay.installed_device: - self.add_error('device_bay_name', - "Device bay ({} {}) is already occupied".format(parent, device_bay_name)) - else: - self.instance.parent_bay = device_bay + self.instance.parent_bay = DeviceBay.objects.get(device=parent, name=device_bay_name) + # Inherit site and rack from parent device + self.instance.site = parent.site + self.instance.rack = parent.rack except DeviceBay.DoesNotExist: - self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name)) - - -class DeviceImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=DeviceFromCSVForm) - - -class ChildDeviceImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ChildDeviceFromCSVForm) + raise forms.ValidationError("Parent device/bay ({} {}) not found".format(parent, device_bay_name)) class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -752,6 +855,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], '{} ({})'.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') @@ -763,18 +873,21 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')), label='Rack group', ) + rack_id = FilterChoiceField( + queryset=Rack.objects.annotate(filter_count=Count('devices')), + label='Rack', + null_option=(0, 'None'), + ) role = FilterChoiceField( queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug', ) tenant = FilterChoiceField( - queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug', + 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'), @@ -786,14 +899,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') # @@ -830,92 +937,112 @@ class ConsolePortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') -class ConsoleConnectionCSVForm(forms.Form): +class ConsoleConnectionCSVForm(forms.ModelForm): console_server = FlexibleModelChoiceField( queryset=Device.objects.filter(device_type__is_console_server=True), to_field_name='name', + help_text='Console server name or ID', error_messages={ 'invalid_choice': 'Console server not found', } ) - cs_port = forms.CharField() - device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Device not found'}) - console_port = forms.CharField() - status = forms.CharField(validators=[validate_connection_status]) + cs_port = forms.CharField( + help_text='Console server port name' + ) + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Device name or ID', + error_messages={ + 'invalid_choice': 'Device not found', + } + ) + console_port = forms.CharField( + help_text='Console port name' + ) + connection_status = CSVChoiceField( + choices=CONNECTION_STATUS_CHOICES, + help_text='Connection status' + ) - def clean(self): + class Meta: + model = ConsolePort + fields = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] - # Validate console server port - if self.cleaned_data.get('console_server'): - try: - cs_port = ConsoleServerPort.objects.get(device=self.cleaned_data['console_server'], - name=self.cleaned_data['cs_port']) - if ConsolePort.objects.filter(cs_port=cs_port): - raise forms.ValidationError("Console server port is already occupied (by {} {})" - .format(cs_port.connected_console.device, cs_port.connected_console)) - except ConsoleServerPort.DoesNotExist: - raise forms.ValidationError("Invalid console server port ({} {})" - .format(self.cleaned_data['console_server'], self.cleaned_data['cs_port'])) + def clean_console_port(self): - # Validate console port - if self.cleaned_data.get('device'): - try: - console_port = ConsolePort.objects.get(device=self.cleaned_data['device'], - name=self.cleaned_data['console_port']) - if console_port.cs_port: - raise forms.ValidationError("Console port is already connected (to {} {})" - .format(console_port.cs_port.device, console_port.cs_port)) - except ConsolePort.DoesNotExist: - raise forms.ValidationError("Invalid console port ({} {})" - .format(self.cleaned_data['device'], self.cleaned_data['console_port'])) + console_port_name = self.cleaned_data.get('console_port') + if not self.cleaned_data.get('device') or not console_port_name: + return None + + try: + # Retrieve console port by name + consoleport = ConsolePort.objects.get( + device=self.cleaned_data['device'], name=console_port_name + ) + # Check if the console port is already connected + if consoleport.cs_port is not None: + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['device'], console_port_name + )) + except ConsolePort.DoesNotExist: + raise forms.ValidationError("Invalid console port ({} {})".format( + self.cleaned_data['device'], console_port_name + )) + + self.instance = consoleport + return consoleport + + def clean_cs_port(self): + + cs_port_name = self.cleaned_data.get('cs_port') + if not self.cleaned_data.get('console_server') or not cs_port_name: + return None + + try: + # Retrieve console server port by name + cs_port = ConsoleServerPort.objects.get( + device=self.cleaned_data['console_server'], name=cs_port_name + ) + # Check if the console server port is already connected + if ConsolePort.objects.filter(cs_port=cs_port).count(): + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['console_server'], cs_port_name + )) + except ConsoleServerPort.DoesNotExist: + raise forms.ValidationError("Invalid console server port ({} {})".format( + self.cleaned_data['console_server'], cs_port_name + )) + + return cs_port -class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=ConsoleConnectionCSVForm) - - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return - - connection_list = [] - - for i, record in enumerate(records, start=1): - form = self.fields['csv'].csv_form(data=record) - if form.is_valid(): - console_port = ConsolePort.objects.get(device=form.cleaned_data['device'], - name=form.cleaned_data['console_port']) - console_port.cs_port = ConsoleServerPort.objects.get(device=form.cleaned_data['console_server'], - name=form.cleaned_data['cs_port']) - if form.cleaned_data['status'] == 'planned': - console_port.connection_status = CONNECTION_STATUS_PLANNED - else: - console_port.connection_status = CONNECTION_STATUS_CONNECTED - connection_list.append(console_port) - else: - for field, errors in form.errors.items(): - for e in errors: - self.add_error('csv', "Record {} {}: {}".format(i, field, e)) - - self.cleaned_data['csv'] = connection_list - - -class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): +class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), - widget=forms.HiddenInput(), - ) - rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), - label='Rack', required=False, widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', attrs={'filter-for': 'console_server', 'nullable': 'true'} ) ) - console_server = forms.ModelChoiceField( - queryset=Device.objects.all(), + console_server = ChainedModelChoiceField( + queryset=Device.objects.filter(device_type__is_console_server=True), + chains=( + ('site', 'site'), + ('rack', 'rack'), + ), label='Console Server', required=False, widget=APISelect( @@ -929,15 +1056,18 @@ 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', ) ) - cs_port = forms.ModelChoiceField( + cs_port = ChainedModelChoiceField( queryset=ConsoleServerPort.objects.all(), + chains=( + ('device', 'console_server'), + ), label='Port', widget=APISelect( - api_url='/api/dcim/devices/{{console_server}}/console-server-ports/', + api_url='/api/dcim/console-server-ports/?device_id={{console_server}}', disabled_indicator='connected_console', ) ) @@ -957,32 +1087,6 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): if not self.instance.pk: raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.") - # Initialize rack choices if site is set - if self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Initialize console_server choices if rack or site is set - if self.initial.get('rack'): - self.fields['console_server'].queryset = Device.objects.filter( - rack=self.initial['rack'], device_type__is_console_server=True - ) - elif self.initial.get('site'): - self.fields['console_server'].queryset = Device.objects.filter( - site=self.initial['site'], rack__isnull=True, device_type__is_console_server=True - ) - else: - self.fields['console_server'].choices = [] - - # Initialize CS port choices if console_server is set - if self.initial.get('console_server'): - self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter( - device=self.initial['console_server'] - ) - else: - self.fields['cs_port'].choices = [] - # # Console server ports @@ -1002,21 +1106,32 @@ class ConsoleServerPortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') -class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): +class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): site = forms.ModelChoiceField( queryset=Site.objects.all(), - widget=forms.HiddenInput(), - ) - rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), - label='Rack', required=False, widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', attrs={'filter-for': 'device', 'nullable': 'true'} ) ) - device = forms.ModelChoiceField( + device = ChainedModelChoiceField( queryset=Device.objects.all(), + chains=( + ('site', 'site'), + ('rack', 'rack'), + ), label='Device', required=False, widget=APISelect( @@ -1030,15 +1145,18 @@ 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' ) ) - port = forms.ModelChoiceField( + port = ChainedModelChoiceField( queryset=ConsolePort.objects.all(), + chains=( + ('device', 'device'), + ), 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' ) ) @@ -1057,29 +1175,9 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): 'connection_status': 'Status', } - def __init__(self, *args, **kwargs): - super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs) - - # Initialize rack choices if site is set - if self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Initialize device choices if rack or site is set - if self.initial.get('rack'): - self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) - elif self.initial.get('site'): - self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True) - else: - self.fields['device'].choices = [] - - # Initialize port choices if device is set - if self.initial.get('device'): - self.fields['port'].queryset = ConsolePort.objects.filter(device=self.initial['device']) - else: - self.fields['port'].choices = [] +class ConsoleServerPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput) # @@ -1100,90 +1198,112 @@ class PowerPortCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') -class PowerConnectionCSVForm(forms.Form): +class PowerConnectionCSVForm(forms.ModelForm): pdu = FlexibleModelChoiceField( queryset=Device.objects.filter(device_type__is_pdu=True), to_field_name='name', + help_text='PDU name or ID', error_messages={ 'invalid_choice': 'PDU not found.', } ) - power_outlet = forms.CharField() - device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Device not found'}) - power_port = forms.CharField() - status = forms.CharField(validators=[validate_connection_status]) + power_outlet = forms.CharField( + help_text='Power outlet name' + ) + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Device name or ID', + error_messages={ + 'invalid_choice': 'Device not found', + } + ) + power_port = forms.CharField( + help_text='Power port name' + ) + connection_status = CSVChoiceField( + choices=CONNECTION_STATUS_CHOICES, + help_text='Connection status' + ) - def clean(self): + class Meta: + model = PowerPort + fields = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] - # Validate power outlet - if self.cleaned_data.get('pdu'): - try: - power_outlet = PowerOutlet.objects.get(device=self.cleaned_data['pdu'], - name=self.cleaned_data['power_outlet']) - if PowerPort.objects.filter(power_outlet=power_outlet): - raise forms.ValidationError("Power outlet is already occupied (by {} {})" - .format(power_outlet.connected_port.device, - power_outlet.connected_port)) - except PowerOutlet.DoesNotExist: - raise forms.ValidationError("Invalid PDU port ({} {})" - .format(self.cleaned_data['pdu'], self.cleaned_data['power_outlet'])) + def clean_power_port(self): - # Validate power port - if self.cleaned_data.get('device'): - try: - power_port = PowerPort.objects.get(device=self.cleaned_data['device'], - name=self.cleaned_data['power_port']) - if power_port.power_outlet: - raise forms.ValidationError("Power port is already connected (to {} {})" - .format(power_port.power_outlet.device, power_port.power_outlet)) - except PowerPort.DoesNotExist: - raise forms.ValidationError("Invalid power port ({} {})" - .format(self.cleaned_data['device'], self.cleaned_data['power_port'])) + power_port_name = self.cleaned_data.get('power_port') + if not self.cleaned_data.get('device') or not power_port_name: + return None + + try: + # Retrieve power port by name + powerport = PowerPort.objects.get( + device=self.cleaned_data['device'], name=power_port_name + ) + # Check if the power port is already connected + if powerport.power_outlet is not None: + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['device'], power_port_name + )) + except PowerPort.DoesNotExist: + raise forms.ValidationError("Invalid power port ({} {})".format( + self.cleaned_data['device'], power_port_name + )) + + self.instance = powerport + return powerport + + def clean_power_outlet(self): + + power_outlet_name = self.cleaned_data.get('power_outlet') + if not self.cleaned_data.get('pdu') or not power_outlet_name: + return None + + try: + # Retrieve power outlet by name + power_outlet = PowerOutlet.objects.get( + device=self.cleaned_data['pdu'], name=power_outlet_name + ) + # Check if the power outlet is already connected + if PowerPort.objects.filter(power_outlet=power_outlet).count(): + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['pdu'], power_outlet_name + )) + except PowerOutlet.DoesNotExist: + raise forms.ValidationError("Invalid power outlet ({} {})".format( + self.cleaned_data['pdu'], power_outlet_name + )) + + return power_outlet -class PowerConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=PowerConnectionCSVForm) - - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return - - connection_list = [] - - for i, record in enumerate(records, start=1): - form = self.fields['csv'].csv_form(data=record) - if form.is_valid(): - power_port = PowerPort.objects.get(device=form.cleaned_data['device'], - name=form.cleaned_data['power_port']) - power_port.power_outlet = PowerOutlet.objects.get(device=form.cleaned_data['pdu'], - name=form.cleaned_data['power_outlet']) - if form.cleaned_data['status'] == 'planned': - power_port.connection_status = CONNECTION_STATUS_PLANNED - else: - power_port.connection_status = CONNECTION_STATUS_CONNECTED - connection_list.append(power_port) - else: - for field, errors in form.errors.items(): - for e in errors: - self.add_error('csv', "Record {} {}: {}".format(i, field, e)) - - self.cleaned_data['csv'] = connection_list - - -class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput()) - rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), - label='Rack', +class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), required=False, widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', attrs={'filter-for': 'pdu', 'nullable': 'true'} ) ) - pdu = forms.ModelChoiceField( + pdu = ChainedModelChoiceField( queryset=Device.objects.all(), + chains=( + ('site', 'site'), + ('rack', 'rack'), + ), label='PDU', required=False, widget=APISelect( @@ -1197,15 +1317,18 @@ 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' ) ) - power_outlet = forms.ModelChoiceField( + power_outlet = ChainedModelChoiceField( queryset=PowerOutlet.objects.all(), + chains=( + ('device', 'pdu'), + ), label='Outlet', widget=APISelect( - api_url='/api/dcim/devices/{{pdu}}/power-outlets/', + api_url='/api/dcim/power-outlets/?device_id={{pdu}}', disabled_indicator='connected_port' ) ) @@ -1225,30 +1348,6 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): if not self.instance.pk: raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.") - # Initialize rack choices if site is set - if self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Initialize pdu choices if rack or site is set - if self.initial.get('rack'): - self.fields['pdu'].queryset = Device.objects.filter( - rack=self.initial['rack'], device_type__is_pdu=True - ) - elif self.initial.get('site'): - self.fields['pdu'].queryset = Device.objects.filter( - site=self.initial['site'], rack__isnull=True, device_type__is_pdu=True - ) - else: - self.fields['pdu'].choices = [] - - # Initialize power outlet choices if pdu is set - if self.initial.get('pdu'): - self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device=self.initial['pdu']) - else: - self.fields['power_outlet'].choices = [] - # # Power outlets @@ -1268,21 +1367,32 @@ class PowerOutletCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') -class PowerOutletConnectionForm(BootstrapMixin, forms.Form): +class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): site = forms.ModelChoiceField( queryset=Site.objects.all(), - widget=forms.HiddenInput() - ) - rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), - label='Rack', required=False, widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'site'), + ), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', attrs={'filter-for': 'device', 'nullable': 'true'} ) ) - device = forms.ModelChoiceField( + device = ChainedModelChoiceField( queryset=Device.objects.all(), + chains=( + ('site', 'site'), + ('rack', 'rack'), + ), label='Device', required=False, widget=APISelect( @@ -1296,15 +1406,18 @@ 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' ) ) - port = forms.ModelChoiceField( + port = ChainedModelChoiceField( queryset=PowerPort.objects.all(), + chains=( + ('device', 'device'), + ), 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' ) ) @@ -1323,29 +1436,9 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): 'connection_status': 'Status', } - def __init__(self, *args, **kwargs): - super(PowerOutletConnectionForm, self).__init__(*args, **kwargs) - - # Initialize rack choices if site is set - if self.initial.get('site'): - self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) - else: - self.fields['rack'].choices = [] - - # Initialize device choices if rack or site is set - if self.initial.get('rack'): - self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) - elif self.initial.get('site'): - self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True) - else: - self.fields['device'].choices = [] - - # Initialize port choices if device is set - if self.initial.get('device'): - self.fields['port'].queryset = PowerPort.objects.filter(device=self.initial['device']) - else: - self.fields['port'].choices = [] +class PowerOutletBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput) # @@ -1356,7 +1449,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface - fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] + fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description'] widgets = { 'device': forms.HiddenInput(), } @@ -1378,12 +1471,19 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class InterfaceCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) + enabled = forms.BooleanField(required=False) lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mac_address = MACAddressFormField(required=False, label='MAC Address') mgmt_only = forms.BooleanField(required=False, label='OOB Management') description = forms.CharField(max_length=100, required=False) def __init__(self, *args, **kwargs): + + # Set interfaces enabled by default + kwargs['initial'] = kwargs.get('initial', {}).copy() + kwargs['initial'].update({'enabled': True}) + super(InterfaceCreateForm, self).__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device @@ -1398,30 +1498,44 @@ class InterfaceCreateForm(DeviceComponentForm): class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput) - lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) + enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) + lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') + mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only') description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['lag', 'description'] + nullable_fields = ['lag', 'mtu', 'description'] def __init__(self, *args, **kwargs): super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) # Limit LAG choices to interfaces which belong to the parent device. + device = None if self.initial.get('device'): - self.fields['lag'].queryset = Interface.objects.filter( - device=self.initial['device'], form_factor=IFACE_FF_LAG + try: + device = Device.objects.get(pk=self.initial.get('device')) + except Device.DoesNotExist: + pass + if device is not None: + interface_ordering = device.device_type.interface_ordering + self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter( + device=device, form_factor=IFACE_FF_LAG ) else: self.fields['lag'].choices = [] +class InterfaceBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) + + # # Interface connections # -class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): +class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): interface_a = forms.ChoiceField( choices=[], widget=SelectWithDisabled, @@ -1435,8 +1549,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): attrs={'filter-for': 'rack_b'} ) ) - rack_b = forms.ModelChoiceField( + rack_b = ChainedModelChoiceField( queryset=Rack.objects.all(), + chains=( + ('site', 'site_b'), + ), label='Rack', required=False, widget=APISelect( @@ -1444,8 +1561,12 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): attrs={'filter-for': 'device_b', 'nullable': 'true'} ) ) - device_b = forms.ModelChoiceField( + device_b = ChainedModelChoiceField( queryset=Device.objects.all(), + chains=( + ('site', 'site_b'), + ('rack', 'rack_b'), + ), label='Device', required=False, widget=APISelect( @@ -1459,15 +1580,20 @@ 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' ) ) - interface_b = forms.ModelChoiceField( - queryset=Interface.objects.all(), + interface_b = ChainedModelChoiceField( + queryset=Interface.objects.connectable().select_related( + 'circuit_termination', 'connected_as_a', 'connected_as_b' + ), + chains=( + ('device', 'device_b'), + ), 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' ) ) @@ -1481,135 +1607,96 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): super(InterfaceConnectionForm, self).__init__(*args, **kwargs) # Initialize interface A choices - device_a_interfaces = Interface.objects.order_naturally().filter(device=device_a).exclude( - form_factor__in=VIRTUAL_IFACE_TYPES - ).select_related( + device_a_interfaces = Interface.objects.connectable().order_naturally().filter(device=device_a).select_related( 'circuit_termination', 'connected_as_a', 'connected_as_b' ) self.fields['interface_a'].choices = [ (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces ] - # Initialize rack_b choices if site_b is set - if self.initial.get('site_b'): - self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b']) - else: - self.fields['rack_b'].choices = [] - - # Initialize device_b choices if rack_b or site_b is set - if self.initial.get('rack_b'): - self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b']) - elif self.initial.get('site_b'): - self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True) - else: - self.fields['device_b'].choices = [] - - # Initialize interface_b choices if device_b is set - if self.initial.get('device_b'): - device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude( - form_factor__in=VIRTUAL_IFACE_TYPES - ).select_related( - 'circuit_termination', 'connected_as_a', 'connected_as_b' - ) - else: - device_b_interfaces = [] - self.fields['interface_b'].choices = [ - (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces - ] + # Mark connected interfaces as disabled + if self.data.get('device_b'): + self.fields['interface_b'].choices = [ + (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset + ] -class InterfaceConnectionCSVForm(forms.Form): +class InterfaceConnectionCSVForm(forms.ModelForm): device_a = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', + help_text='Name or ID of device A', error_messages={'invalid_choice': 'Device A not found.'} ) - interface_a = forms.CharField() + interface_a = forms.CharField( + help_text='Name of interface A' + ) device_b = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', + help_text='Name or ID of device B', error_messages={'invalid_choice': 'Device B not found.'} ) - interface_b = forms.CharField() - status = forms.CharField( - validators=[validate_connection_status] + interface_b = forms.CharField( + help_text='Name of interface B' + ) + connection_status = CSVChoiceField( + choices=CONNECTION_STATUS_CHOICES, + help_text='Connection status' ) - def clean(self): + class Meta: + model = InterfaceConnection + fields = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] - # Validate interface A - if self.cleaned_data.get('device_a'): - try: - interface_a = Interface.objects.get(device=self.cleaned_data['device_a'], - name=self.cleaned_data['interface_a']) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface ({} {})" - .format(self.cleaned_data['device_a'], self.cleaned_data['interface_a'])) - try: - InterfaceConnection.objects.get(Q(interface_a=interface_a) | Q(interface_b=interface_a)) - raise forms.ValidationError("{} {} is already connected" - .format(self.cleaned_data['device_a'], self.cleaned_data['interface_a'])) - except InterfaceConnection.DoesNotExist: - pass + def clean_interface_a(self): - # Validate interface B - if self.cleaned_data.get('device_b'): - try: - interface_b = Interface.objects.get(device=self.cleaned_data['device_b'], - name=self.cleaned_data['interface_b']) - except Interface.DoesNotExist: - raise forms.ValidationError("Invalid interface ({} {})" - .format(self.cleaned_data['device_b'], self.cleaned_data['interface_b'])) - try: - InterfaceConnection.objects.get(Q(interface_a=interface_b) | Q(interface_b=interface_b)) - raise forms.ValidationError("{} {} is already connected" - .format(self.cleaned_data['device_b'], self.cleaned_data['interface_b'])) - except InterfaceConnection.DoesNotExist: - pass + interface_name = self.cleaned_data.get('interface_a') + if not interface_name: + return None + + try: + # Retrieve interface by name + interface = Interface.objects.get( + device=self.cleaned_data['device_a'], name=interface_name + ) + # Check for an existing connection to this interface + if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count(): + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['device_a'], interface_name + )) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface ({} {})".format( + self.cleaned_data['device_a'], interface_name + )) + + return interface + + def clean_interface_b(self): + + interface_name = self.cleaned_data.get('interface_b') + if not interface_name: + return None + + try: + # Retrieve interface by name + interface = Interface.objects.get( + device=self.cleaned_data['device_b'], name=interface_name + ) + # Check for an existing connection to this interface + if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count(): + raise forms.ValidationError("{} {} is already connected".format( + self.cleaned_data['device_b'], interface_name + )) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface ({} {})".format( + self.cleaned_data['device_b'], interface_name + )) + + return interface -class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=InterfaceConnectionCSVForm) - - def clean(self): - records = self.cleaned_data.get('csv') - if not records: - return - - connection_list = [] - occupied_interfaces = [] - - for i, record in enumerate(records, start=1): - form = self.fields['csv'].csv_form(data=record) - if form.is_valid(): - interface_a = Interface.objects.get(device=form.cleaned_data['device_a'], - name=form.cleaned_data['interface_a']) - if interface_a in occupied_interfaces: - raise forms.ValidationError("{} {} found in multiple connections" - .format(interface_a.device.name, interface_a.name)) - interface_b = Interface.objects.get(device=form.cleaned_data['device_b'], - name=form.cleaned_data['interface_b']) - if interface_b in occupied_interfaces: - raise forms.ValidationError("{} {} found in multiple connections" - .format(interface_b.device.name, interface_b.name)) - connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b) - if form.cleaned_data['status'] == 'planned': - connection.connection_status = CONNECTION_STATUS_PLANNED - else: - connection.connection_status = CONNECTION_STATUS_CONNECTED - connection_list.append(connection) - occupied_interfaces.append(interface_a) - occupied_interfaces.append(interface_b) - else: - for field, errors in form.errors.items(): - for e in errors: - self.add_error('csv', "Record {} {}: {}".format(i, field, e)) - - self.cleaned_data['csv'] = connection_list - - -class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form): - confirm = forms.BooleanField(required=True) +class InterfaceConnectionDeletionForm(ConfirmationForm): # Used for HTTP redirect upon successful deletion device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False) @@ -1672,41 +1759,11 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # -# IP addresses +# Inventory items # -class IPAddressForm(BootstrapMixin, CustomFieldForm): - set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False) +class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: - model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description'] - - def __init__(self, device, *args, **kwargs): - - super(IPAddressForm, self).__init__(*args, **kwargs) - - self.fields['vrf'].empty_label = 'Global' - - interfaces = device.interfaces.all() - self.fields['interface'].queryset = interfaces - self.fields['interface'].required = True - - # If this device has only one interface, select it by default. - if len(interfaces) == 1: - self.fields['interface'].initial = interfaces[0] - - # If this device does not have any IP addresses assigned, default to setting the first IP as its primary. - if not IPAddress.objects.filter(interface__device=device).count(): - self.fields['set_as_primary'].initial = True - - -# -# Modules -# - -class ModuleForm(BootstrapMixin, forms.ModelForm): - - class Meta: - model = Module - fields = ['name', 'manufacturer', 'part_id', 'serial'] + model = InventoryItem + fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] diff --git a/netbox/dcim/migrations/0033_rackreservation_rack_editable.py b/netbox/dcim/migrations/0033_rackreservation_rack_editable.py new file mode 100644 index 000000000..b327bad12 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py b/netbox/dcim/migrations/0034_rename_module_to_inventoryitem.py new file mode 100644 index 000000000..ff430c067 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/migrations/0035_device_expand_status_choices.py b/netbox/dcim/migrations/0035_device_expand_status_choices.py new file mode 100644 index 000000000..16ea807c9 --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py b/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py new file mode 100644 index 000000000..ac0f89f41 --- /dev/null +++ b/netbox/dcim/migrations/0036_add_ff_juniper_vcp.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-09 16:00 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0035_device_expand_status_choices'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus'], [5200, b'Juniper VCP']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus'], [5200, b'Juniper VCP']]], [b'Other', [[32767, b'Other']]]], default=1200), + ), + ] diff --git a/netbox/dcim/migrations/0037_unicode_literals.py b/netbox/dcim/migrations/0037_unicode_literals.py new file mode 100644 index 000000000..cba05becc --- /dev/null +++ b/netbox/dcim/migrations/0037_unicode_literals.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-05-24 15:34 +from __future__ import unicode_literals + +import dcim.fields +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0036_add_ff_juniper_vcp'), + ] + + operations = [ + migrations.AlterField( + model_name='consoleport', + name='connection_status', + field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True), + ), + migrations.AlterField( + model_name='consoleport', + name='cs_port', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name='Console server port'), + ), + migrations.AlterField( + model_name='device', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name='Asset tag'), + ), + migrations.AlterField( + model_name='device', + name='face', + field=models.PositiveSmallIntegerField(blank=True, choices=[[0, 'Front'], [1, 'Rear']], null=True, verbose_name='Rack face'), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.PositiveSmallIntegerField(blank=True, help_text='The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Position (U)'), + ), + migrations.AlterField( + model_name='device', + name='primary_ip4', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name='Primary IPv4'), + ), + migrations.AlterField( + model_name='device', + name='primary_ip6', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name='Primary IPv6'), + ), + migrations.AlterField( + model_name='device', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + migrations.AlterField( + model_name='device', + name='status', + field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [2, 'Planned'], [3, 'Staged'], [4, 'Failed'], [5, 'Inventory']], default=1, verbose_name='Status'), + ), + migrations.AlterField( + model_name='devicebay', + name='name', + field=models.CharField(max_length=50, verbose_name='Name'), + ), + migrations.AlterField( + model_name='devicetype', + name='interface_ordering', + field=models.PositiveSmallIntegerField(choices=[[1, 'Slot/position'], [2, 'Name (alphabetically)']], default=1), + ), + migrations.AlterField( + model_name='devicetype', + name='is_console_server', + field=models.BooleanField(default=False, help_text='This type of device has console server ports', verbose_name='Is a console server'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_full_depth', + field=models.BooleanField(default=True, help_text='Device consumes both front and rear rack faces', verbose_name='Is full depth'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_network_device', + field=models.BooleanField(default=True, help_text='This type of device has network interfaces', verbose_name='Is a network device'), + ), + migrations.AlterField( + model_name='devicetype', + name='is_pdu', + field=models.BooleanField(default=False, help_text='This type of device has power outlets', verbose_name='Is a PDU'), + ), + migrations.AlterField( + model_name='devicetype', + name='part_number', + field=models.CharField(blank=True, help_text='Discrete part number (optional)', max_length=50), + ), + migrations.AlterField( + model_name='devicetype', + name='subdevice_role', + field=models.NullBooleanField(choices=[(None, 'None'), (True, 'Parent'), (False, 'Child')], default=None, help_text='Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name='Parent/child status'), + ), + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.PositiveSmallIntegerField(default=1, verbose_name='Height (U)'), + ), + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interface', + name='lag', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name='Parent LAG'), + ), + migrations.AlterField( + model_name='interface', + name='mac_address', + field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address'), + ), + migrations.AlterField( + model_name='interface', + name='mgmt_only', + field=models.BooleanField(default=False, help_text='This interface is used only for out-of-band management', verbose_name='OOB Management'), + ), + migrations.AlterField( + model_name='interfaceconnection', + name='connection_status', + field=models.BooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True, verbose_name='Status'), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='mgmt_only', + field=models.BooleanField(default=False, verbose_name='Management only'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='discovered', + field=models.BooleanField(default=False, verbose_name='Discovered'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='name', + field=models.CharField(max_length=50, verbose_name='Name'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='part_id', + field=models.CharField(blank=True, max_length=50, verbose_name='Part ID'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='serial', + field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'), + ), + migrations.AlterField( + model_name='platform', + name='rpc_client', + field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='RPC client'), + ), + migrations.AlterField( + model_name='powerport', + name='connection_status', + field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True), + ), + migrations.AlterField( + model_name='rack', + name='desc_units', + field=models.BooleanField(default=False, help_text='Units are numbered top-to-bottom', verbose_name='Descending units'), + ), + migrations.AlterField( + model_name='rack', + name='facility_id', + field=utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name='Facility ID'), + ), + migrations.AlterField( + model_name='rack', + name='type', + field=models.PositiveSmallIntegerField(blank=True, choices=[(100, '2-post frame'), (200, '4-post frame'), (300, '4-post cabinet'), (1000, 'Wall-mounted frame'), (1100, 'Wall-mounted cabinet')], null=True, verbose_name='Type'), + ), + migrations.AlterField( + model_name='rack', + name='u_height', + field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Height (U)'), + ), + migrations.AlterField( + model_name='rack', + name='width', + field=models.PositiveSmallIntegerField(choices=[(19, '19 inches'), (23, '23 inches')], default=19, help_text='Rail-to-rail width', verbose_name='Width'), + ), + migrations.AlterField( + model_name='site', + name='asn', + field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'), + ), + migrations.AlterField( + model_name='site', + name='contact_email', + field=models.EmailField(blank=True, max_length=254, verbose_name='Contact E-mail'), + ), + ] diff --git a/netbox/dcim/migrations/0038_wireless_interfaces.py b/netbox/dcim/migrations/0038_wireless_interfaces.py new file mode 100644 index 000000000..61cdb3996 --- /dev/null +++ b/netbox/dcim/migrations/0038_wireless_interfaces.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-06-16 21:38 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0037_unicode_literals'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + ] diff --git a/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py b/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py new file mode 100644 index 000000000..4cc7e9616 --- /dev/null +++ b/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-06-23 17:05 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0038_wireless_interfaces'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='enabled', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='interface', + name='mtu', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU'), + ), + ] diff --git a/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py b/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py new file mode 100644 index 000000000..c7d49fe2c --- /dev/null +++ b/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-06-23 20:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0039_interface_add_enabled_mtu'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryitem', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this item', max_length=50, null=True, unique=True, verbose_name='Asset tag'), + ), + migrations.AddField( + model_name='inventoryitem', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/dcim/migrations/0041_napalm_integration.py b/netbox/dcim/migrations/0041_napalm_integration.py new file mode 100644 index 000000000..73ca8f3ee --- /dev/null +++ b/netbox/dcim/migrations/0041_napalm_integration.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-14 17:26 +from __future__ import unicode_literals + +from django.db import migrations, models + + +def rpc_client_to_napalm_driver(apps, schema_editor): + """ + Migrate legacy RPC clients to their respective NAPALM drivers + """ + Platform = apps.get_model('dcim', 'Platform') + + Platform.objects.filter(rpc_client='juniper-junos').update(napalm_driver='junos') + Platform.objects.filter(rpc_client='cisco-ios').update(napalm_driver='ios') + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0040_inventoryitem_add_asset_tag_description'), + ] + + operations = [ + migrations.AlterModelOptions( + name='device', + options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + ), + migrations.AddField( + model_name='platform', + name='napalm_driver', + field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices.', max_length=50, verbose_name='NAPALM driver'), + ), + migrations.AlterField( + model_name='platform', + name='rpc_client', + field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='Legacy RPC client'), + ), + migrations.RunPython(rpc_client_to_napalm_driver), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 73678ae1c..8dd11e663 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,4 +1,6 @@ +from __future__ import unicode_literals from collections import OrderedDict +from itertools import count, groupby from mptt.models import MPTTModel, TreeForeignKey @@ -8,200 +10,24 @@ 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 from utilities.managers import NaturalOrderByManager from utilities.models import CreatedUpdatedModel from utilities.utils import csv_format - +from .constants import * from .fields import ASNField, MACAddressField -RACK_TYPE_2POST = 100 -RACK_TYPE_4POST = 200 -RACK_TYPE_CABINET = 300 -RACK_TYPE_WALLFRAME = 1000 -RACK_TYPE_WALLCABINET = 1100 -RACK_TYPE_CHOICES = ( - (RACK_TYPE_2POST, '2-post frame'), - (RACK_TYPE_4POST, '4-post frame'), - (RACK_TYPE_CABINET, '4-post cabinet'), - (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'), - (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'), -) - -RACK_WIDTH_19IN = 19 -RACK_WIDTH_23IN = 23 -RACK_WIDTH_CHOICES = ( - (RACK_WIDTH_19IN, '19 inches'), - (RACK_WIDTH_23IN, '23 inches'), -) - -RACK_FACE_FRONT = 0 -RACK_FACE_REAR = 1 -RACK_FACE_CHOICES = [ - [RACK_FACE_FRONT, 'Front'], - [RACK_FACE_REAR, 'Rear'], -] - -SUBDEVICE_ROLE_PARENT = True -SUBDEVICE_ROLE_CHILD = False -SUBDEVICE_ROLE_CHOICES = ( - (None, 'None'), - (SUBDEVICE_ROLE_PARENT, 'Parent'), - (SUBDEVICE_ROLE_CHILD, 'Child'), -) - -IFACE_ORDERING_POSITION = 1 -IFACE_ORDERING_NAME = 2 -IFACE_ORDERING_CHOICES = [ - [IFACE_ORDERING_POSITION, 'Slot/position'], - [IFACE_ORDERING_NAME, 'Name (alphabetically)'] -] - -# Virtual -IFACE_FF_VIRTUAL = 0 -IFACE_FF_LAG = 200 -# Ethernet -IFACE_FF_100ME_FIXED = 800 -IFACE_FF_1GE_FIXED = 1000 -IFACE_FF_1GE_GBIC = 1050 -IFACE_FF_1GE_SFP = 1100 -IFACE_FF_10GE_FIXED = 1150 -IFACE_FF_10GE_SFP_PLUS = 1200 -IFACE_FF_10GE_XFP = 1300 -IFACE_FF_10GE_XENPAK = 1310 -IFACE_FF_10GE_X2 = 1320 -IFACE_FF_25GE_SFP28 = 1350 -IFACE_FF_40GE_QSFP_PLUS = 1400 -IFACE_FF_100GE_CFP = 1500 -IFACE_FF_100GE_QSFP28 = 1600 -# Fibrechannel -IFACE_FF_1GFC_SFP = 3010 -IFACE_FF_2GFC_SFP = 3020 -IFACE_FF_4GFC_SFP = 3040 -IFACE_FF_8GFC_SFP_PLUS = 3080 -IFACE_FF_16GFC_SFP_PLUS = 3160 -# Serial -IFACE_FF_T1 = 4000 -IFACE_FF_E1 = 4010 -IFACE_FF_T3 = 4040 -IFACE_FF_E3 = 4050 -# Stacking -IFACE_FF_STACKWISE = 5000 -IFACE_FF_STACKWISE_PLUS = 5050 -IFACE_FF_FLEXSTACK = 5100 -IFACE_FF_FLEXSTACK_PLUS = 5150 -# Other -IFACE_FF_OTHER = 32767 - -IFACE_FF_CHOICES = [ - [ - 'Virtual interfaces', - [ - [IFACE_FF_VIRTUAL, 'Virtual'], - [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], - ] - ], - [ - 'Ethernet (fixed)', - [ - [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'], - [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'], - [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'], - ] - ], - [ - 'Ethernet (modular)', - [ - [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'], - [IFACE_FF_1GE_SFP, 'SFP (1GE)'], - [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'], - [IFACE_FF_10GE_XFP, 'XFP (10GE)'], - [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'], - [IFACE_FF_10GE_X2, 'X2 (10GE)'], - [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], - [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], - [IFACE_FF_100GE_CFP, 'CFP (100GE)'], - [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], - ] - ], - [ - 'FibreChannel', - [ - [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'], - [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'], - [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'], - [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], - [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], - ] - ], - [ - 'Serial', - [ - [IFACE_FF_T1, 'T1 (1.544 Mbps)'], - [IFACE_FF_E1, 'E1 (2.048 Mbps)'], - [IFACE_FF_T3, 'T3 (45 Mbps)'], - [IFACE_FF_E3, 'E3 (34 Mbps)'], - [IFACE_FF_E3, 'E3 (34 Mbps)'], - ] - ], - [ - 'Stacking', - [ - [IFACE_FF_STACKWISE, 'Cisco StackWise'], - [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], - [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], - [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], - ] - ], - [ - 'Other', - [ - [IFACE_FF_OTHER, 'Other'], - ] - ], -] - -VIRTUAL_IFACE_TYPES = [ - IFACE_FF_VIRTUAL, - IFACE_FF_LAG, -] - -STATUS_ACTIVE = True -STATUS_OFFLINE = False -STATUS_CHOICES = [ - [STATUS_ACTIVE, 'Active'], - [STATUS_OFFLINE, 'Offline'], -] - -CONNECTION_STATUS_PLANNED = False -CONNECTION_STATUS_CONNECTED = True -CONNECTION_STATUS_CHOICES = [ - [CONNECTION_STATUS_PLANNED, 'Planned'], - [CONNECTION_STATUS_CONNECTED, 'Connected'], -] - -# For mapping platform -> NC client -RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos' -RPC_CLIENT_CISCO_IOS = 'cisco-ios' -RPC_CLIENT_OPENGEAR = 'opengear' -RPC_CLIENT_CHOICES = [ - [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'], - [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'], - [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'], -] - - # # Regions # @@ -211,7 +37,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) @@ -254,9 +82,14 @@ 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() + csv_headers = [ + 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email', + ] + class Meta: ordering = ['name'] @@ -313,7 +146,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'] @@ -323,7 +156,7 @@ class RackGroup(models.Model): ] def __str__(self): - return u'{} - {}'.format(self.site.name, self.name) + return self.name def get_absolute_url(self): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) @@ -375,9 +208,14 @@ 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() + csv_headers = [ + 'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units', + ] + class Meta: ordering = ['site', 'name'] unique_together = [ @@ -386,7 +224,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): ] def __str__(self): - return self.display_name + return self.display_name or super(Rack, self).__str__() def get_absolute_url(self): return reverse('dcim:rack', args=[self.pk]) @@ -442,8 +280,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): @property def display_name(self): if self.facility_id: - return u"{} ({})".format(self.name, self.facility_id) - return self.name + return "{} ({})".format(self.name, self.facility_id) + elif self.name: + return self.name + return "" def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False): """ @@ -533,7 +373,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) @@ -543,7 +383,7 @@ class RackReservation(models.Model): ordering = ['created'] def __str__(self): - return u"Reservation for rack {}".format(self.rack) + return "Reservation for rack {}".format(self.rack) def clean(self): @@ -553,7 +393,7 @@ class RackReservation(models.Model): invalid_units = [u for u in self.units if u not in self.rack.units] if invalid_units: raise ValidationError({ - 'units': u"Invalid unit(s) for {}U rack: {}".format( + 'units': "Invalid unit(s) for {}U rack: {}".format( self.rack.u_height, ', '.join([str(u) for u in invalid_units]), ), @@ -571,6 +411,15 @@ class RackReservation(models.Model): ) }) + @property + def unit_list(self): + """ + Express the assigned units as a string of summarized ranges. For example: + [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16" + """ + group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x)) + return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) + # # Device Types @@ -698,7 +547,7 @@ class DeviceType(models.Model, CustomFieldModel): @property def full_name(self): - return u'{} {}'.format(self.manufacturer.name, self.model) + return '{} {}'.format(self.manufacturer.name, self.model) @property def is_parent_device(self): @@ -773,17 +622,17 @@ class PowerOutletTemplate(models.Model): return self.name -class InterfaceManager(models.Manager): +class InterfaceQuerySet(models.QuerySet): def order_naturally(self, method=IFACE_ORDERING_POSITION): """ - Naturally order interfaces by their name and numeric position. The sort method must be one of the defined + Naturally order interfaces by their type and numeric position. The sort method must be one of the defined IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). - To order interfaces naturally, the `name` field is split into five distinct components: leading text (name), - slot, subslot, position, and channel: + To order interfaces naturally, the `name` field is split into six distinct components: leading text (type), + slot, subslot, position, channel, and virtual circuit: - {name}{slot}/{subslot}/{position}:{channel} + {type}{slot}/{subslot}/{position}:{channel}.{vc} Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would be parsed as follows: @@ -793,23 +642,32 @@ class InterfaceManager(models.Manager): subslot = 0 position = 1 channel = None + vc = 0 - The chosen sorting method will determine which fields are ordered first in the query. + The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of + the prescribed fields. """ - queryset = self.get_queryset() - sql_col = '{}.name'.format(queryset.model._meta.db_table) + sql_col = '{}.name'.format(self.model._meta.db_table) ordering = { - IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'), - IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'), + IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'), + IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'), }[method] - return queryset.extra(select={ - '_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), - '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col), - '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col), - '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col), - '_channel': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col), + return self.extra(select={ + '_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), + '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), + '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), + '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), + '_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col), + '_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col), }).order_by(*ordering) + def connectable(self): + """ + Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or + wireless). + """ + return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) + @python_2_unicode_compatible class InterfaceTemplate(models.Model): @@ -821,7 +679,7 @@ class InterfaceTemplate(models.Model): form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) mgmt_only = models.BooleanField(default=False, verbose_name='Management only') - objects = InterfaceManager() + objects = InterfaceQuerySet.as_manager() class Meta: ordering = ['device_type', 'name'] @@ -880,7 +738,10 @@ class Platform(models.Model): """ name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, verbose_name='RPC client') + napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver', + help_text="The name of the NAPALM driver to use when interacting with devices.") + rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, + verbose_name='Legacy RPC client') class Meta: ordering = ['name'] @@ -904,11 +765,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) @@ -917,30 +778,47 @@ 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() + csv_headers = [ + 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', + 'site', 'rack_group', 'rack_name', 'position', 'face', + ] + class Meta: ordering = ['name'] unique_together = ['rack', 'position', 'face'] + permissions = ( + ('napalm_read', 'Read-only access to devices via NAPALM'), + ('napalm_write', 'Read/write access to devices via NAPALM'), + ) def __str__(self): - return self.display_name + return self.display_name or super(Device, self).__str__() def get_absolute_url(self): return reverse('dcim:device', args=[self.pk]) @@ -1048,7 +926,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel): self.platform.name if self.platform else None, self.serial, self.asset_tag, + self.get_status_display(), self.site.name, + self.rack.group.name if self.rack and self.rack.group else None, self.rack.name if self.rack else None, self.position, self.get_face_display(), @@ -1058,12 +938,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel): def display_name(self): if self.name: return self.name - elif self.position: - return u"{} ({} U{})".format(self.device_type, self.rack.name, self.position) - elif self.rack: - return u"{} ({})".format(self.device_type, self.rack.name) - else: - return u"{} ({})".format(self.device_type, self.site.name) + elif hasattr(self, 'device_type'): + return "{}".format(self.device_type) + return "" @property def identifier(self): @@ -1091,6 +968,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. @@ -1115,6 +995,8 @@ class ConsolePort(models.Model): verbose_name='Console server port', blank=True, null=True) connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + csv_headers = ['console_server', 'cs_port', 'device', 'console_port', 'connection_status'] + class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -1184,6 +1066,8 @@ class PowerPort(models.Model): blank=True, null=True) connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status'] + class Meta: ordering = ['device', 'name'] unique_together = ['device', 'name'] @@ -1243,16 +1127,27 @@ class Interface(models.Model): of an InterfaceConnection. """ device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) - lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL, - verbose_name='Parent LAG') + lag = models.ForeignKey( + 'self', + models.SET_NULL, + related_name='member_interfaces', + null=True, + blank=True, + verbose_name='Parent LAG' + ) name = models.CharField(max_length=30) form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) + enabled = models.BooleanField(default=True) mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') - mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management', - help_text="This interface is used only for out-of-band management") + mtu = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU') + mgmt_only = models.BooleanField( + default=False, + verbose_name='OOB Management', + help_text="This interface is used only for out-of-band management" + ) description = models.CharField(max_length=100, blank=True) - objects = InterfaceManager() + objects = InterfaceQuerySet.as_manager() class Meta: ordering = ['device', 'name'] @@ -1264,31 +1159,31 @@ class Interface(models.Model): def clean(self): # Virtual interfaces cannot be connected - if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected: + if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected: raise ValidationError({ - 'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the " - "interface or choose a physical form factor." + 'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " + "Disconnect the interface or choose a suitable form factor." }) # An interface's LAG must belong to the same device if self.lag and self.lag.device != self.device: raise ValidationError({ - 'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format( + 'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format( self.lag.name, self.lag.device.name ) }) # A virtual interface cannot have a parent LAG - if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None: + if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.lag is not None: raise ValidationError({ - 'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) + 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) }) # Only a LAG can have LAG members if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists(): raise ValidationError({ 'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format( - u", ".join([iface.name for iface in self.member_interfaces.all()]) + ", ".join([iface.name for iface in self.member_interfaces.all()]) ) }) @@ -1296,6 +1191,10 @@ class Interface(models.Model): def is_virtual(self): return self.form_factor in VIRTUAL_IFACE_TYPES + @property + def is_wireless(self): + return self.form_factor in WIRELESS_IFACE_TYPES + @property def is_lag(self): return self.form_factor == IFACE_FF_LAG @@ -1345,11 +1244,16 @@ class InterfaceConnection(models.Model): connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, verbose_name='Status') + csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] + def clean(self): - if self.interface_a == self.interface_b: - raise ValidationError({ - 'interface_b': "Cannot connect an interface to itself." - }) + try: + if self.interface_a == self.interface_b: + raise ValidationError({ + 'interface_b': "Cannot connect an interface to itself." + }) + except ObjectDoesNotExist: + pass # Used for connections export def to_csv(self): @@ -1381,7 +1285,7 @@ class DeviceBay(models.Model): unique_together = ['device', 'name'] def __str__(self): - return u'{} - {}'.format(self.device.name, self.name) + return '{} - {}'.format(self.device.name, self.name) def clean(self): @@ -1397,23 +1301,29 @@ 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, - on_delete=models.PROTECT) + manufacturer = models.ForeignKey( + 'Manufacturer', models.PROTECT, related_name='inventory_items', blank=True, null=True + ) part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True) + 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 item' + ) discovered = models.BooleanField(default=False, verbose_name='Discovered') + description = models.CharField(max_length=100, blank=True) class Meta: ordering = ['device__id', 'parent__id', 'name'] diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 6af772fde..427f0bb42 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -1,12 +1,13 @@ +from __future__ import unicode_literals + import django_tables2 as tables from django_tables2.utils import Accessor from utilities.tables import BaseTable, ToggleColumn - from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, - Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, Region, Site, + ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, ) @@ -64,6 +65,12 @@ RACK_ROLE = """ {% endif %} """ +RACKRESERVATION_ACTIONS = """ +{% if perms.dcim.change_rackreservation %} + +{% endif %} +""" + DEVICEROLE_ACTIONS = """ {% if perms.dcim.change_devicerole %} @@ -86,12 +93,8 @@ DEVICE_ROLE = """ """ -STATUS_ICON = """ -{% if record.status %} - -{% else %} - -{% endif %} +DEVICE_STATUS = """ +{{ record.get_status_display }} """ DEVICE_PRIMARY_IP = """ @@ -136,19 +139,23 @@ 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')]) + + class Meta(BaseTable.Meta): + model = Site + fields = ('pk', 'name', 'facility', 'region', 'tenant', 'asn') + + +class SiteDetailTable(SiteTable): 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') vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs') circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits') - class Meta(BaseTable.Meta): - model = Site + class Meta(SiteTable.Meta): fields = ( 'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count', 'circuit_count', @@ -197,20 +204,26 @@ 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') - 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') + + +class RackDetailTable(RackTable): + devices = tables.Column(accessor=Accessor('device_count')) + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') + + class Meta(RackTable.Meta): + fields = ( + 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization' + ) class RackImportTable(BaseTable): @@ -223,7 +236,24 @@ class RackImportTable(BaseTable): class Meta(BaseTable.Meta): model = Rack - fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height') + fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'u_height') + + +# +# Rack reservations +# + +class RackReservationTable(BaseTable): + pk = ToggleColumn() + rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) + unit_list = tables.Column(orderable=False, verbose_name='Units') + actions = tables.TemplateColumn( + template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + ) + + class Meta(BaseTable.Meta): + model = RackReservation + fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions') # @@ -249,9 +279,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') @@ -263,7 +291,7 @@ class DeviceTypeTable(BaseTable): model = DeviceType fields = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', 'instance_count' + 'is_network_device', 'subdevice_role', 'instance_count', ) @@ -278,7 +306,6 @@ class ConsolePortTemplateTable(BaseTable): model = ConsolePortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class ConsoleServerPortTemplateTable(BaseTable): @@ -288,7 +315,6 @@ class ConsoleServerPortTemplateTable(BaseTable): model = ConsoleServerPortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class PowerPortTemplateTable(BaseTable): @@ -298,7 +324,6 @@ class PowerPortTemplateTable(BaseTable): model = PowerPortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class PowerOutletTemplateTable(BaseTable): @@ -308,17 +333,16 @@ class PowerOutletTemplateTable(BaseTable): model = PowerOutletTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class InterfaceTemplateTable(BaseTable): pk = ToggleColumn() + mgmt_only = tables.TemplateColumn("{% if value %}OOB Management{% endif %}") class Meta(BaseTable.Meta): model = InterfaceTemplate - fields = ('pk', 'name', 'form_factor') + fields = ('pk', 'name', 'mgmt_only', 'form_factor') empty_text = "None" - show_header = False class DeviceBayTemplateTable(BaseTable): @@ -328,7 +352,6 @@ class DeviceBayTemplateTable(BaseTable): model = DeviceBayTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False # @@ -358,12 +381,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') # @@ -372,24 +396,35 @@ 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 + ) class Meta(BaseTable.Meta): + model = Device + fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type') + + +class DeviceDetailTable(DeviceTable): + primary_ip = tables.TemplateColumn( + orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP + ) + + class Meta(DeviceTable.Meta): model = Device fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip') class DeviceImportTable(BaseTable): name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') + status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status') 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') @@ -399,10 +434,56 @@ class DeviceImportTable(BaseTable): class Meta(BaseTable.Meta): model = Device - fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') + fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False +# +# Device components +# + +class ConsolePortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = ConsolePort + fields = ('name',) + + +class ConsoleServerPortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = ConsoleServerPort + fields = ('name',) + + +class PowerPortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = PowerPort + fields = ('name',) + + +class PowerOutletTable(BaseTable): + + class Meta(BaseTable.Meta): + model = PowerOutlet + fields = ('name',) + + +class InterfaceTable(BaseTable): + + class Meta(BaseTable.Meta): + model = Interface + fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description') + + +class DeviceBayTable(BaseTable): + + class Meta(BaseTable.Meta): + model = DeviceBay + fields = ('name',) + + # # Device connections # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py new file mode 100644 index 000000000..9fe191cc7 --- /dev/null +++ b/netbox/dcim/tests/test_api.py @@ -0,0 +1,2160 @@ +from __future__ import unicode_literals + +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 ( + ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection, InterfaceTemplate, + Manufacturer, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, + RackReservation, RackRole, Region, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, +) +from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE +from users.models import Token +from utilities.tests import HttpStatusMixin + + +class RegionTest(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.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + self.region3 = Region.objects.create(name='Test Region 3', slug='test-region-3') + + def test_get_region(self): + + url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.region1.name) + + def test_list_regions(self): + + url = reverse('dcim-api:region-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_region(self): + + data = { + 'name': 'Test Region 4', + 'slug': 'test-region-4', + } + + url = reverse('dcim-api:region-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Region.objects.count(), 4) + region4 = Region.objects.get(pk=response.data['id']) + self.assertEqual(region4.name, data['name']) + self.assertEqual(region4.slug, data['slug']) + + def test_update_region(self): + + data = { + 'name': 'Test Region X', + 'slug': 'test-region-x', + } + + url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Region.objects.count(), 3) + region1 = Region.objects.get(pk=response.data['id']) + self.assertEqual(region1.name, data['name']) + self.assertEqual(region1.slug, data['slug']) + + def test_delete_region(self): + + url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Region.objects.count(), 2) + + +class SiteTest(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.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') + self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') + self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='test-site-1') + self.site2 = Site.objects.create(region=self.region1, name='Test Site 2', slug='test-site-2') + self.site3 = Site.objects.create(region=self.region1, name='Test Site 3', slug='test-site-3') + + def test_get_site(self): + + url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.site1.name) + + def test_get_site_graphs(self): + + self.graph1 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 1', + source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1' + ) + self.graph2 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 2', + source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2' + ) + self.graph3 = Graph.objects.create( + type=GRAPH_TYPE_SITE, name='Test Graph 3', + source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3' + ) + + url = reverse('dcim-api:site-graphs', kwargs={'pk': self.site1.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?site=test-site-1&foo=1') + + def test_list_sites(self): + + url = reverse('dcim-api:site-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_site(self): + + data = { + 'name': 'Test Site 4', + 'slug': 'test-site-4', + 'region': self.region1.pk, + } + + url = reverse('dcim-api:site-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Site.objects.count(), 4) + site4 = Site.objects.get(pk=response.data['id']) + self.assertEqual(site4.name, data['name']) + self.assertEqual(site4.slug, data['slug']) + self.assertEqual(site4.region_id, data['region']) + + def test_update_site(self): + + data = { + 'name': 'Test Site X', + 'slug': 'test-site-x', + 'region': self.region2.pk, + } + + url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Site.objects.count(), 3) + site1 = Site.objects.get(pk=response.data['id']) + self.assertEqual(site1.name, data['name']) + self.assertEqual(site1.slug, data['slug']) + self.assertEqual(site1.region_id, data['region']) + + def test_delete_site(self): + + url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Site.objects.count(), 2) + + +class RackGroupTest(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.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2') + self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3') + + def test_get_rackgroup(self): + + url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.rackgroup1.name) + + def test_list_rackgroups(self): + + url = reverse('dcim-api:rackgroup-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rackgroup(self): + + data = { + 'name': 'Test Rack Group 4', + 'slug': 'test-rack-group-4', + 'site': self.site1.pk, + } + + url = reverse('dcim-api:rackgroup-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(RackGroup.objects.count(), 4) + rackgroup4 = RackGroup.objects.get(pk=response.data['id']) + self.assertEqual(rackgroup4.name, data['name']) + self.assertEqual(rackgroup4.slug, data['slug']) + self.assertEqual(rackgroup4.site_id, data['site']) + + def test_update_rackgroup(self): + + data = { + 'name': 'Test Rack Group X', + 'slug': 'test-rack-group-x', + 'site': self.site2.pk, + } + + url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(RackGroup.objects.count(), 3) + rackgroup1 = RackGroup.objects.get(pk=response.data['id']) + self.assertEqual(rackgroup1.name, data['name']) + self.assertEqual(rackgroup1.slug, data['slug']) + self.assertEqual(rackgroup1.site_id, data['site']) + + def test_delete_rackgroup(self): + + url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(RackGroup.objects.count(), 2) + + +class RackRoleTest(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.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') + self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') + self.rackrole3 = RackRole.objects.create(name='Test Rack Role 3', slug='test-rack-role-3', color='0000ff') + + def test_get_rackrole(self): + + url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.rackrole1.name) + + def test_list_rackroles(self): + + url = reverse('dcim-api:rackrole-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rackrole(self): + + data = { + 'name': 'Test Rack Role 4', + 'slug': 'test-rack-role-4', + 'color': 'ffff00', + } + + url = reverse('dcim-api:rackrole-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(RackRole.objects.count(), 4) + rackrole1 = RackRole.objects.get(pk=response.data['id']) + self.assertEqual(rackrole1.name, data['name']) + self.assertEqual(rackrole1.slug, data['slug']) + self.assertEqual(rackrole1.color, data['color']) + + def test_update_rackrole(self): + + data = { + 'name': 'Test Rack Role X', + 'slug': 'test-rack-role-x', + 'color': 'ffff00', + } + + url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(RackRole.objects.count(), 3) + rackrole1 = RackRole.objects.get(pk=response.data['id']) + self.assertEqual(rackrole1.name, data['name']) + self.assertEqual(rackrole1.slug, data['slug']) + self.assertEqual(rackrole1.color, data['color']) + + def test_delete_rackrole(self): + + url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(RackRole.objects.count(), 2) + + +class RackTest(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.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.rackgroup2 = RackGroup.objects.create(site=self.site2, name='Test Rack Group 2', slug='test-rack-group-2') + self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') + self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00') + self.rack1 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42, + ) + self.rack2 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42, + ) + self.rack3 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42, + ) + + def test_get_rack(self): + + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.rack1.name) + + def test_get_rack_units(self): + + url = reverse('dcim-api:rack-units', kwargs={'pk': self.rack1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 42) + + def test_list_racks(self): + + url = reverse('dcim-api:rack-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rack(self): + + data = { + 'name': 'Test Rack 4', + 'site': self.site1.pk, + 'group': self.rackgroup1.pk, + 'role': self.rackrole1.pk, + } + + url = reverse('dcim-api:rack-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Rack.objects.count(), 4) + rack4 = Rack.objects.get(pk=response.data['id']) + self.assertEqual(rack4.name, data['name']) + self.assertEqual(rack4.site_id, data['site']) + self.assertEqual(rack4.group_id, data['group']) + self.assertEqual(rack4.role_id, data['role']) + + def test_update_rack(self): + + data = { + 'name': 'Test Rack X', + 'site': self.site2.pk, + 'group': self.rackgroup2.pk, + 'role': self.rackrole2.pk, + } + + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Rack.objects.count(), 3) + rack1 = Rack.objects.get(pk=response.data['id']) + self.assertEqual(rack1.name, data['name']) + self.assertEqual(rack1.site_id, data['site']) + self.assertEqual(rack1.group_id, data['group']) + self.assertEqual(rack1.role_id, data['role']) + + def test_delete_rack(self): + + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Rack.objects.count(), 2) + + +class RackReservationTest(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.user1 = user + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.rack1 = Rack.objects.create(site=self.site1, name='Test Rack 1') + self.rackreservation1 = RackReservation.objects.create( + rack=self.rack1, units=[1, 2, 3], user=user, description='First reservation', + ) + self.rackreservation2 = RackReservation.objects.create( + rack=self.rack1, units=[4, 5, 6], user=user, description='Second reservation', + ) + self.rackreservation3 = RackReservation.objects.create( + rack=self.rack1, units=[7, 8, 9], user=user, description='Third reservation', + ) + + def test_get_rackreservation(self): + + url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['id'], self.rackreservation1.pk) + + def test_list_rackreservations(self): + + url = reverse('dcim-api:rackreservation-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_rackreservation(self): + + data = { + 'rack': self.rack1.pk, + 'units': [10, 11, 12], + 'user': self.user1.pk, + 'description': 'Fourth reservation', + } + + url = reverse('dcim-api:rackreservation-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(RackReservation.objects.count(), 4) + rackreservation4 = RackReservation.objects.get(pk=response.data['id']) + self.assertEqual(rackreservation4.rack_id, data['rack']) + self.assertEqual(rackreservation4.units, data['units']) + self.assertEqual(rackreservation4.user_id, data['user']) + self.assertEqual(rackreservation4.description, data['description']) + + def test_update_rackreservation(self): + + data = { + 'rack': self.rack1.pk, + 'units': [10, 11, 12], + 'user': self.user1.pk, + 'description': 'Modified reservation', + } + + url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(RackReservation.objects.count(), 3) + rackreservation1 = RackReservation.objects.get(pk=response.data['id']) + self.assertEqual(rackreservation1.units, data['units']) + self.assertEqual(rackreservation1.description, data['description']) + + def test_delete_rackreservation(self): + + url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(RackReservation.objects.count(), 2) + + +class ManufacturerTest(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.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') + self.manufacturer3 = Manufacturer.objects.create(name='Test Manufacturer 3', slug='test-manufacturer-3') + + def test_get_manufacturer(self): + + url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.manufacturer1.name) + + def test_list_manufacturers(self): + + url = reverse('dcim-api:manufacturer-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_manufacturer(self): + + data = { + 'name': 'Test Manufacturer 4', + 'slug': 'test-manufacturer-4', + } + + url = reverse('dcim-api:manufacturer-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Manufacturer.objects.count(), 4) + manufacturer4 = Manufacturer.objects.get(pk=response.data['id']) + self.assertEqual(manufacturer4.name, data['name']) + self.assertEqual(manufacturer4.slug, data['slug']) + + def test_update_manufacturer(self): + + data = { + 'name': 'Test Manufacturer X', + 'slug': 'test-manufacturer-x', + } + + url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Manufacturer.objects.count(), 3) + manufacturer1 = Manufacturer.objects.get(pk=response.data['id']) + self.assertEqual(manufacturer1.name, data['name']) + self.assertEqual(manufacturer1.slug, data['slug']) + + def test_delete_manufacturer(self): + + url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Manufacturer.objects.count(), 2) + + +class DeviceTypeTest(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.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2') + self.devicetype1 = DeviceType.objects.create( + manufacturer=self.manufacturer1, model='Test Device Type 1', slug='test-device-type-1' + ) + self.devicetype2 = DeviceType.objects.create( + manufacturer=self.manufacturer1, model='Test Device Type 2', slug='test-device-type-2' + ) + self.devicetype3 = DeviceType.objects.create( + manufacturer=self.manufacturer1, model='Test Device Type 3', slug='test-device-type-3' + ) + + def test_get_devicetype(self): + + url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['model'], self.devicetype1.model) + + def test_list_devicetypes(self): + + url = reverse('dcim-api:devicetype-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_devicetype(self): + + data = { + 'manufacturer': self.manufacturer1.pk, + 'model': 'Test Device Type 4', + 'slug': 'test-device-type-4', + } + + url = reverse('dcim-api:devicetype-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(DeviceType.objects.count(), 4) + devicetype4 = DeviceType.objects.get(pk=response.data['id']) + self.assertEqual(devicetype4.manufacturer_id, data['manufacturer']) + self.assertEqual(devicetype4.model, data['model']) + self.assertEqual(devicetype4.slug, data['slug']) + + def test_update_devicetype(self): + + data = { + 'manufacturer': self.manufacturer2.pk, + 'model': 'Test Device Type X', + 'slug': 'test-device-type-x', + } + + url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(DeviceType.objects.count(), 3) + devicetype1 = DeviceType.objects.get(pk=response.data['id']) + self.assertEqual(devicetype1.manufacturer_id, data['manufacturer']) + self.assertEqual(devicetype1.model, data['model']) + self.assertEqual(devicetype1.slug, data['slug']) + + def test_delete_devicetype(self): + + url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(DeviceType.objects.count(), 2) + + +class ConsolePortTemplateTest(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.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.consoleporttemplate1 = ConsolePortTemplate.objects.create( + device_type=self.devicetype, name='Test CP Template 1' + ) + self.consoleporttemplate2 = ConsolePortTemplate.objects.create( + device_type=self.devicetype, name='Test CP Template 2' + ) + self.consoleporttemplate3 = ConsolePortTemplate.objects.create( + device_type=self.devicetype, name='Test CP Template 3' + ) + + def test_get_consoleporttemplate(self): + + url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.consoleporttemplate1.name) + + def test_list_consoleporttemplates(self): + + url = reverse('dcim-api:consoleporttemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_consoleporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test CP Template 4', + } + + url = reverse('dcim-api:consoleporttemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConsolePortTemplate.objects.count(), 4) + consoleporttemplate4 = ConsolePortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(consoleporttemplate4.device_type_id, data['device_type']) + self.assertEqual(consoleporttemplate4.name, data['name']) + + def test_update_consoleporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test CP Template X', + } + + url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ConsolePortTemplate.objects.count(), 3) + consoleporttemplate1 = ConsolePortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(consoleporttemplate1.name, data['name']) + + def test_delete_consoleporttemplate(self): + + url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConsolePortTemplate.objects.count(), 2) + + +class ConsoleServerPortTemplateTest(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.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.consoleserverporttemplate1 = ConsoleServerPortTemplate.objects.create( + device_type=self.devicetype, name='Test CSP Template 1' + ) + self.consoleserverporttemplate2 = ConsoleServerPortTemplate.objects.create( + device_type=self.devicetype, name='Test CSP Template 2' + ) + self.consoleserverporttemplate3 = ConsoleServerPortTemplate.objects.create( + device_type=self.devicetype, name='Test CSP Template 3' + ) + + def test_get_consoleserverporttemplate(self): + + url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.consoleserverporttemplate1.name) + + def test_list_consoleserverporttemplates(self): + + url = reverse('dcim-api:consoleserverporttemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_consoleserverporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test CSP Template 4', + } + + url = reverse('dcim-api:consoleserverporttemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConsoleServerPortTemplate.objects.count(), 4) + consoleserverporttemplate4 = ConsoleServerPortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(consoleserverporttemplate4.device_type_id, data['device_type']) + self.assertEqual(consoleserverporttemplate4.name, data['name']) + + def test_update_consoleserverporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test CSP Template X', + } + + url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ConsoleServerPortTemplate.objects.count(), 3) + consoleserverporttemplate1 = ConsoleServerPortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(consoleserverporttemplate1.name, data['name']) + + def test_delete_consoleserverporttemplate(self): + + url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConsoleServerPortTemplate.objects.count(), 2) + + +class PowerPortTemplateTest(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.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.powerporttemplate1 = PowerPortTemplate.objects.create( + device_type=self.devicetype, name='Test PP Template 1' + ) + self.powerporttemplate2 = PowerPortTemplate.objects.create( + device_type=self.devicetype, name='Test PP Template 2' + ) + self.powerporttemplate3 = PowerPortTemplate.objects.create( + device_type=self.devicetype, name='Test PP Template 3' + ) + + def test_get_powerporttemplate(self): + + url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.powerporttemplate1.name) + + def test_list_powerporttemplates(self): + + url = reverse('dcim-api:powerporttemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_powerporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test PP Template 4', + } + + url = reverse('dcim-api:powerporttemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerPortTemplate.objects.count(), 4) + powerporttemplate4 = PowerPortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(powerporttemplate4.device_type_id, data['device_type']) + self.assertEqual(powerporttemplate4.name, data['name']) + + def test_update_powerporttemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test PP Template X', + } + + url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(PowerPortTemplate.objects.count(), 3) + powerporttemplate1 = PowerPortTemplate.objects.get(pk=response.data['id']) + self.assertEqual(powerporttemplate1.name, data['name']) + + def test_delete_powerporttemplate(self): + + url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerPortTemplate.objects.count(), 2) + + +class PowerOutletTemplateTest(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.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.poweroutlettemplate1 = PowerOutletTemplate.objects.create( + device_type=self.devicetype, name='Test PO Template 1' + ) + self.poweroutlettemplate2 = PowerOutletTemplate.objects.create( + device_type=self.devicetype, name='Test PO Template 2' + ) + self.poweroutlettemplate3 = PowerOutletTemplate.objects.create( + device_type=self.devicetype, name='Test PO Template 3' + ) + + def test_get_poweroutlettemplate(self): + + url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.poweroutlettemplate1.name) + + def test_list_poweroutlettemplates(self): + + url = reverse('dcim-api:poweroutlettemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_poweroutlettemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test PO Template 4', + } + + url = reverse('dcim-api:poweroutlettemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerOutletTemplate.objects.count(), 4) + poweroutlettemplate4 = PowerOutletTemplate.objects.get(pk=response.data['id']) + self.assertEqual(poweroutlettemplate4.device_type_id, data['device_type']) + self.assertEqual(poweroutlettemplate4.name, data['name']) + + def test_update_poweroutlettemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test PO Template X', + } + + url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(PowerOutletTemplate.objects.count(), 3) + poweroutlettemplate1 = PowerOutletTemplate.objects.get(pk=response.data['id']) + self.assertEqual(poweroutlettemplate1.name, data['name']) + + def test_delete_poweroutlettemplate(self): + + url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerOutletTemplate.objects.count(), 2) + + +class InterfaceTemplateTest(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.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.interfacetemplate1 = InterfaceTemplate.objects.create( + device_type=self.devicetype, name='Test Interface Template 1' + ) + self.interfacetemplate2 = InterfaceTemplate.objects.create( + device_type=self.devicetype, name='Test Interface Template 2' + ) + self.interfacetemplate3 = InterfaceTemplate.objects.create( + device_type=self.devicetype, name='Test Interface Template 3' + ) + + def test_get_interfacetemplate(self): + + url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.interfacetemplate1.name) + + def test_list_interfacetemplates(self): + + url = reverse('dcim-api:interfacetemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_interfacetemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test Interface Template 4', + } + + url = reverse('dcim-api:interfacetemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(InterfaceTemplate.objects.count(), 4) + interfacetemplate4 = InterfaceTemplate.objects.get(pk=response.data['id']) + self.assertEqual(interfacetemplate4.device_type_id, data['device_type']) + self.assertEqual(interfacetemplate4.name, data['name']) + + def test_update_interfacetemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test Interface Template X', + } + + url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(InterfaceTemplate.objects.count(), 3) + interfacetemplate1 = InterfaceTemplate.objects.get(pk=response.data['id']) + self.assertEqual(interfacetemplate1.name, data['name']) + + def test_delete_interfacetemplate(self): + + url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(InterfaceTemplate.objects.count(), 2) + + +class DeviceBayTemplateTest(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.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.devicebaytemplate1 = DeviceBayTemplate.objects.create( + device_type=self.devicetype, name='Test Device Bay Template 1' + ) + self.devicebaytemplate2 = DeviceBayTemplate.objects.create( + device_type=self.devicetype, name='Test Device Bay Template 2' + ) + self.devicebaytemplate3 = DeviceBayTemplate.objects.create( + device_type=self.devicetype, name='Test Device Bay Template 3' + ) + + def test_get_devicebaytemplate(self): + + url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.devicebaytemplate1.name) + + def test_list_devicebaytemplates(self): + + url = reverse('dcim-api:devicebaytemplate-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_devicebaytemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test Device Bay Template 4', + } + + url = reverse('dcim-api:devicebaytemplate-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(DeviceBayTemplate.objects.count(), 4) + devicebaytemplate4 = DeviceBayTemplate.objects.get(pk=response.data['id']) + self.assertEqual(devicebaytemplate4.device_type_id, data['device_type']) + self.assertEqual(devicebaytemplate4.name, data['name']) + + def test_update_devicebaytemplate(self): + + data = { + 'device_type': self.devicetype.pk, + 'name': 'Test Device Bay Template X', + } + + url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(DeviceBayTemplate.objects.count(), 3) + devicebaytemplate1 = DeviceBayTemplate.objects.get(pk=response.data['id']) + self.assertEqual(devicebaytemplate1.name, data['name']) + + def test_delete_devicebaytemplate(self): + + url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(DeviceBayTemplate.objects.count(), 2) + + +class DeviceRoleTest(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.devicerole1 = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.devicerole2 = DeviceRole.objects.create( + name='Test Device Role 2', slug='test-device-role-2', color='00ff00' + ) + self.devicerole3 = DeviceRole.objects.create( + name='Test Device Role 3', slug='test-device-role-3', color='0000ff' + ) + + def test_get_devicerole(self): + + url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.devicerole1.name) + + def test_list_deviceroles(self): + + url = reverse('dcim-api:devicerole-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_devicerole(self): + + data = { + 'name': 'Test Device Role 4', + 'slug': 'test-device-role-4', + 'color': 'ffff00', + } + + url = reverse('dcim-api:devicerole-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(DeviceRole.objects.count(), 4) + devicerole4 = DeviceRole.objects.get(pk=response.data['id']) + self.assertEqual(devicerole4.name, data['name']) + self.assertEqual(devicerole4.slug, data['slug']) + self.assertEqual(devicerole4.color, data['color']) + + def test_update_devicerole(self): + + data = { + 'name': 'Test Device Role X', + 'slug': 'test-device-role-x', + 'color': '00ffff', + } + + url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(DeviceRole.objects.count(), 3) + devicerole1 = DeviceRole.objects.get(pk=response.data['id']) + self.assertEqual(devicerole1.name, data['name']) + self.assertEqual(devicerole1.slug, data['slug']) + self.assertEqual(devicerole1.color, data['color']) + + def test_delete_devicerole(self): + + url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(DeviceRole.objects.count(), 2) + + +class PlatformTest(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.platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1') + self.platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2') + self.platform3 = Platform.objects.create(name='Test Platform 3', slug='test-platform-3') + + def test_get_platform(self): + + url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.platform1.name) + + def test_list_platforms(self): + + url = reverse('dcim-api:platform-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_platform(self): + + data = { + 'name': 'Test Platform 4', + 'slug': 'test-platform-4', + } + + url = reverse('dcim-api:platform-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Platform.objects.count(), 4) + platform4 = Platform.objects.get(pk=response.data['id']) + self.assertEqual(platform4.name, data['name']) + self.assertEqual(platform4.slug, data['slug']) + + def test_update_platform(self): + + data = { + 'name': 'Test Platform X', + 'slug': 'test-platform-x', + } + + url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Platform.objects.count(), 3) + platform1 = Platform.objects.get(pk=response.data['id']) + self.assertEqual(platform1.name, data['name']) + self.assertEqual(platform1.slug, data['slug']) + + def test_delete_platform(self): + + url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Platform.objects.count(), 2) + + +class DeviceTest(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.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype1 = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.devicetype2 = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2' + ) + self.devicerole1 = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.devicerole2 = DeviceRole.objects.create( + name='Test Device Role 2', slug='test-device-role-2', color='00ff00' + ) + self.device1 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 1', site=self.site1 + ) + self.device2 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 2', site=self.site1 + ) + self.device3 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 3', site=self.site1 + ) + + def test_get_device(self): + + url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.device1.name) + + def test_list_devices(self): + + url = reverse('dcim-api:device-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_device(self): + + data = { + 'device_type': self.devicetype1.pk, + 'device_role': self.devicerole1.pk, + 'name': 'Test Device 4', + 'site': self.site1.pk, + } + + url = reverse('dcim-api:device-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Device.objects.count(), 4) + device4 = Device.objects.get(pk=response.data['id']) + self.assertEqual(device4.device_type_id, data['device_type']) + self.assertEqual(device4.device_role_id, data['device_role']) + self.assertEqual(device4.name, data['name']) + self.assertEqual(device4.site_id, data['site']) + + def test_update_device(self): + + data = { + 'device_type': self.devicetype2.pk, + 'device_role': self.devicerole2.pk, + 'name': 'Test Device X', + 'site': self.site2.pk, + } + + url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Device.objects.count(), 3) + device1 = Device.objects.get(pk=response.data['id']) + self.assertEqual(device1.device_type_id, data['device_type']) + self.assertEqual(device1.device_role_id, data['device_role']) + self.assertEqual(device1.name, data['name']) + self.assertEqual(device1.site_id, data['site']) + + def test_delete_device(self): + + url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Device.objects.count(), 2) + + +class ConsolePortTest(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', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.consoleport1 = ConsolePort.objects.create(device=self.device, name='Test Console Port 1') + self.consoleport2 = ConsolePort.objects.create(device=self.device, name='Test Console Port 2') + self.consoleport3 = ConsolePort.objects.create(device=self.device, name='Test Console Port 3') + + def test_get_consoleport(self): + + url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.consoleport1.name) + + def test_list_consoleports(self): + + url = reverse('dcim-api:consoleport-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_consoleport(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Console Port 4', + } + + url = reverse('dcim-api:consoleport-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConsolePort.objects.count(), 4) + consoleport4 = ConsolePort.objects.get(pk=response.data['id']) + self.assertEqual(consoleport4.device_id, data['device']) + self.assertEqual(consoleport4.name, data['name']) + + def test_update_consoleport(self): + + consoleserverport = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 1') + + data = { + 'device': self.device.pk, + 'name': 'Test Console Port X', + 'cs_port': consoleserverport.pk, + } + + url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ConsolePort.objects.count(), 3) + consoleport1 = ConsolePort.objects.get(pk=response.data['id']) + self.assertEqual(consoleport1.name, data['name']) + self.assertEqual(consoleport1.cs_port_id, data['cs_port']) + + def test_delete_consoleport(self): + + url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConsolePort.objects.count(), 2) + + +class ConsoleServerPortTest(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', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.consoleserverport1 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 1') + self.consoleserverport2 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 2') + self.consoleserverport3 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 3') + + def test_get_consoleserverport(self): + + url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.consoleserverport1.name) + + def test_list_consoleserverports(self): + + url = reverse('dcim-api:consoleserverport-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_consoleserverport(self): + + data = { + 'device': self.device.pk, + 'name': 'Test CS Port 4', + } + + url = reverse('dcim-api:consoleserverport-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConsoleServerPort.objects.count(), 4) + consoleserverport4 = ConsoleServerPort.objects.get(pk=response.data['id']) + self.assertEqual(consoleserverport4.device_id, data['device']) + self.assertEqual(consoleserverport4.name, data['name']) + + def test_update_consoleserverport(self): + + data = { + 'device': self.device.pk, + 'name': 'Test CS Port X', + } + + url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ConsoleServerPort.objects.count(), 3) + consoleserverport1 = ConsoleServerPort.objects.get(pk=response.data['id']) + self.assertEqual(consoleserverport1.name, data['name']) + + def test_delete_consoleserverport(self): + + url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ConsoleServerPort.objects.count(), 2) + + +class PowerPortTest(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', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.powerport1 = PowerPort.objects.create(device=self.device, name='Test Power Port 1') + self.powerport2 = PowerPort.objects.create(device=self.device, name='Test Power Port 2') + self.powerport3 = PowerPort.objects.create(device=self.device, name='Test Power Port 3') + + def test_get_powerport(self): + + url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.powerport1.name) + + def test_list_powerports(self): + + url = reverse('dcim-api:powerport-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_powerport(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Power Port 4', + } + + url = reverse('dcim-api:powerport-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerPort.objects.count(), 4) + powerport4 = PowerPort.objects.get(pk=response.data['id']) + self.assertEqual(powerport4.device_id, data['device']) + self.assertEqual(powerport4.name, data['name']) + + def test_update_powerport(self): + + poweroutlet = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 1') + + data = { + 'device': self.device.pk, + 'name': 'Test Power Port X', + 'power_outlet': poweroutlet.pk, + } + + url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(PowerPort.objects.count(), 3) + powerport1 = PowerPort.objects.get(pk=response.data['id']) + self.assertEqual(powerport1.name, data['name']) + self.assertEqual(powerport1.power_outlet_id, data['power_outlet']) + + def test_delete_powerport(self): + + url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerPort.objects.count(), 2) + + +class PowerOutletTest(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', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.poweroutlet1 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 1') + self.poweroutlet2 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 2') + self.poweroutlet3 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 3') + + def test_get_poweroutlet(self): + + url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.poweroutlet1.name) + + def test_list_poweroutlets(self): + + url = reverse('dcim-api:poweroutlet-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_poweroutlet(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Power Outlet 4', + } + + url = reverse('dcim-api:poweroutlet-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerOutlet.objects.count(), 4) + poweroutlet4 = PowerOutlet.objects.get(pk=response.data['id']) + self.assertEqual(poweroutlet4.device_id, data['device']) + self.assertEqual(poweroutlet4.name, data['name']) + + def test_update_poweroutlet(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Power Outlet X', + } + + url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(PowerOutlet.objects.count(), 3) + poweroutlet1 = PowerOutlet.objects.get(pk=response.data['id']) + self.assertEqual(poweroutlet1.name, data['name']) + + def test_delete_poweroutlet(self): + + url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerOutlet.objects.count(), 2) + + +class InterfaceTest(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', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1') + self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2') + self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3') + + def test_get_interface(self): + + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.interface1.name) + + def test_get_interface_graphs(self): + + self.graph1 = Graph.objects.create( + type=GRAPH_TYPE_INTERFACE, name='Test Graph 1', + source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1' + ) + self.graph2 = Graph.objects.create( + type=GRAPH_TYPE_INTERFACE, name='Test Graph 2', + source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2' + ) + self.graph3 = Graph.objects.create( + type=GRAPH_TYPE_INTERFACE, name='Test Graph 3', + source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3' + ) + + url = reverse('dcim-api:interface-graphs', kwargs={'pk': self.interface1.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?interface=Test Interface 1&foo=1') + + def test_list_interfaces(self): + + url = reverse('dcim-api:interface-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_interface(self): + + data = { + 'device': self.device.pk, + 'name': 'Test Interface 4', + } + + url = reverse('dcim-api:interface-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.objects.count(), 4) + interface4 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(interface4.device_id, data['device']) + self.assertEqual(interface4.name, data['name']) + + def test_update_interface(self): + + lag_interface = Interface.objects.create( + device=self.device, name='Test LAG Interface', form_factor=IFACE_FF_LAG + ) + + data = { + 'device': self.device.pk, + 'name': 'Test Interface X', + 'lag': lag_interface.pk, + } + + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(Interface.objects.count(), 4) + interface1 = Interface.objects.get(pk=response.data['id']) + self.assertEqual(interface1.name, data['name']) + self.assertEqual(interface1.lag_id, data['lag']) + + def test_delete_interface(self): + + url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(Interface.objects.count(), 2) + + +class DeviceBayTest(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') + self.devicetype1 = DeviceType.objects.create( + manufacturer=manufacturer, model='Parent Device Type', slug='parent-device-type', + subdevice_role=SUBDEVICE_ROLE_PARENT + ) + self.devicetype2 = DeviceType.objects.create( + manufacturer=manufacturer, model='Child Device Type', slug='child-device-type', + subdevice_role=SUBDEVICE_ROLE_CHILD + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.parent_device = Device.objects.create( + device_type=self.devicetype1, device_role=devicerole, name='Parent Device 1', site=site + ) + self.child_device = Device.objects.create( + device_type=self.devicetype2, device_role=devicerole, name='Child Device 1', site=site + ) + self.devicebay1 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 1') + self.devicebay2 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 2') + self.devicebay3 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 3') + + def test_get_devicebay(self): + + url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.devicebay1.name) + + def test_list_devicebays(self): + + url = reverse('dcim-api:devicebay-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_devicebay(self): + + data = { + 'device': self.parent_device.pk, + 'name': 'Test Device Bay 4', + 'installed_device': self.child_device.pk, + } + + url = reverse('dcim-api:devicebay-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(DeviceBay.objects.count(), 4) + devicebay4 = DeviceBay.objects.get(pk=response.data['id']) + self.assertEqual(devicebay4.device_id, data['device']) + self.assertEqual(devicebay4.name, data['name']) + self.assertEqual(devicebay4.installed_device_id, data['installed_device']) + + def test_update_devicebay(self): + + data = { + 'device': self.parent_device.pk, + 'name': 'Test Device Bay X', + 'installed_device': self.child_device.pk, + } + + url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(DeviceBay.objects.count(), 3) + devicebay1 = DeviceBay.objects.get(pk=response.data['id']) + self.assertEqual(devicebay1.name, data['name']) + self.assertEqual(devicebay1.installed_device_id, data['installed_device']) + + def test_delete_devicebay(self): + + url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(DeviceBay.objects.count(), 2) + + +class InventoryItemTest(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') + self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.inventoryitem1 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 1') + self.inventoryitem2 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 2') + self.inventoryitem3 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 3') + + def test_get_inventoryitem(self): + + url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.inventoryitem1.name) + + def test_list_inventoryitems(self): + + url = reverse('dcim-api:inventoryitem-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_inventoryitem(self): + + data = { + 'device': self.device.pk, + 'parent': self.inventoryitem1.pk, + 'name': 'Test Inventory Item 4', + 'manufacturer': self.manufacturer.pk, + } + + url = reverse('dcim-api:inventoryitem-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(InventoryItem.objects.count(), 4) + inventoryitem4 = InventoryItem.objects.get(pk=response.data['id']) + self.assertEqual(inventoryitem4.device_id, data['device']) + self.assertEqual(inventoryitem4.parent_id, data['parent']) + self.assertEqual(inventoryitem4.name, data['name']) + self.assertEqual(inventoryitem4.manufacturer_id, data['manufacturer']) + + def test_update_inventoryitem(self): + + data = { + 'device': self.device.pk, + 'parent': self.inventoryitem1.pk, + 'name': 'Test Inventory Item X', + 'manufacturer': self.manufacturer.pk, + } + + url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(InventoryItem.objects.count(), 3) + inventoryitem1 = InventoryItem.objects.get(pk=response.data['id']) + self.assertEqual(inventoryitem1.device_id, data['device']) + self.assertEqual(inventoryitem1.parent_id, data['parent']) + self.assertEqual(inventoryitem1.name, data['name']) + self.assertEqual(inventoryitem1.manufacturer_id, data['manufacturer']) + + def test_delete_inventoryitem(self): + + url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(InventoryItem.objects.count(), 2) + + +class ConsoleConnectionTest(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', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site + ) + cs_port1 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 1') + cs_port2 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 2') + cs_port3 = ConsoleServerPort.objects.create(device=device1, name='Test CS Port 3') + ConsolePort.objects.create( + device=device2, cs_port=cs_port1, name='Test Console Port 1', connection_status=True + ) + ConsolePort.objects.create( + device=device2, cs_port=cs_port2, name='Test Console Port 2', connection_status=True + ) + ConsolePort.objects.create( + device=device2, cs_port=cs_port3, name='Test Console Port 3', connection_status=True + ) + + def test_list_consoleconnections(self): + + url = reverse('dcim-api:consoleconnections-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + +class PowerConnectionTest(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', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site + ) + power_outlet1 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 1') + power_outlet2 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 2') + power_outlet3 = PowerOutlet.objects.create(device=device1, name='Test Power Outlet 3') + PowerPort.objects.create( + device=device2, power_outlet=power_outlet1, name='Test Power Port 1', connection_status=True + ) + PowerPort.objects.create( + device=device2, power_outlet=power_outlet2, name='Test Power Port 2', connection_status=True + ) + PowerPort.objects.create( + device=device2, power_outlet=power_outlet3, name='Test Power Port 3', connection_status=True + ) + + def test_list_powerconnections(self): + + url = reverse('dcim-api:powerconnections-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + +class InterfaceConnectionTest(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', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1') + self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2') + self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3') + self.interface4 = Interface.objects.create(device=self.device, name='Test Interface 4') + self.interface5 = Interface.objects.create(device=self.device, name='Test Interface 5') + self.interface6 = Interface.objects.create(device=self.device, name='Test Interface 6') + self.interface7 = Interface.objects.create(device=self.device, name='Test Interface 7') + self.interface8 = Interface.objects.create(device=self.device, name='Test Interface 8') + self.interfaceconnection1 = InterfaceConnection.objects.create( + interface_a=self.interface1, interface_b=self.interface2 + ) + self.interfaceconnection2 = InterfaceConnection.objects.create( + interface_a=self.interface3, interface_b=self.interface4 + ) + self.interfaceconnection3 = InterfaceConnection.objects.create( + interface_a=self.interface5, interface_b=self.interface6 + ) + + def test_get_interfaceconnection(self): + + url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['interface_a']['id'], self.interfaceconnection1.interface_a_id) + self.assertEqual(response.data['interface_b']['id'], self.interfaceconnection1.interface_b_id) + + def test_list_interfaceconnections(self): + + url = reverse('dcim-api:interfaceconnection-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_create_interfaceconnection(self): + + data = { + 'interface_a': self.interface7.pk, + 'interface_b': self.interface8.pk, + } + + url = reverse('dcim-api:interfaceconnection-list') + response = self.client.post(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(InterfaceConnection.objects.count(), 4) + interfaceconnection4 = InterfaceConnection.objects.get(pk=response.data['id']) + self.assertEqual(interfaceconnection4.interface_a_id, data['interface_a']) + self.assertEqual(interfaceconnection4.interface_b_id, data['interface_b']) + + def test_update_interfaceconnection(self): + + new_connection_status = not self.interfaceconnection1.connection_status + + data = { + 'interface_a': self.interface7.pk, + 'interface_b': self.interface8.pk, + 'connection_status': new_connection_status, + } + + url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) + response = self.client.put(url, data, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(InterfaceConnection.objects.count(), 3) + interfaceconnection1 = InterfaceConnection.objects.get(pk=response.data['id']) + self.assertEqual(interfaceconnection1.interface_a_id, data['interface_a']) + self.assertEqual(interfaceconnection1.interface_b_id, data['interface_b']) + self.assertEqual(interfaceconnection1.connection_status, data['connection_status']) + + def test_delete_interfaceconnection(self): + + url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(InterfaceConnection.objects.count(), 2) + + +class ConnectedDeviceTest(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.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + self.devicetype1 = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + self.devicetype2 = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2' + ) + self.devicerole1 = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.devicerole2 = DeviceRole.objects.create( + name='Test Device Role 2', slug='test-device-role-2', color='00ff00' + ) + self.device1 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1 + ) + self.device2 = Device.objects.create( + device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1 + ) + self.interface1 = Interface.objects.create(device=self.device1, name='eth0') + self.interface2 = Interface.objects.create(device=self.device2, name='eth0') + InterfaceConnection.objects.create(interface_a=self.interface1, interface_b=self.interface2) + + def test_get_connected_device(self): + + url = reverse('dcim-api:connected-device-list') + response = self.client.get(url + '?peer-device=TestDevice2&peer-interface=eth0', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['name'], self.device1.name) diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py deleted file mode 100644 index 3c56ab109..000000000 --- a/netbox/dcim/tests/test_apis.py +++ /dev/null @@ -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), - ) diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index f859fe5e1..acf71411e 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -1,4 +1,7 @@ +from __future__ import unicode_literals + from django.test import TestCase + from dcim.forms import * from dcim.models import * diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index d1b721cb0..340c58092 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,4 +1,7 @@ +from __future__ import unicode_literals + from django.test import TestCase + from dcim.models import * diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 7fde6e9b3..172f634fb 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,126 +1,137 @@ +from __future__ import unicode_literals + from django.conf.urls import url -from ipam.views import ServiceEditView +from extras.views import ImageAttachmentEditView +from ipam.views import ServiceCreateView from secrets.views import secret_add - +from .models import Device, Rack, Site from . import views +app_name = 'dcim' urlpatterns = [ # Regions url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), - url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'), + url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'), url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), # Sites url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), - url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'), + url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'), url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'), url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), - url(r'^sites/(?P[\w-]+)/$', views.site, name='site'), + url(r'^sites/(?P[\w-]+)/$', views.SiteView.as_view(), name='site'), url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), + url(r'^sites/(?P\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'), - url(r'^rack-groups/add/$', views.RackGroupEditView.as_view(), name='rackgroup_add'), + url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'), url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), url(r'^rack-groups/(?P\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), # Rack roles url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), - url(r'^rack-roles/add/$', views.RackRoleEditView.as_view(), name='rackrole_add'), + url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'), url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), # Rack reservations + url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'), + url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), url(r'^rack-reservations/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), # 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'), url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), - url(r'^racks/(?P\d+)/$', views.rack, name='rack'), + url(r'^racks/(?P\d+)/$', views.RackView.as_view(), name='rack'), url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), - url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'), + url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), + url(r'^racks/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), - url(r'^manufacturers/add/$', views.ManufacturerEditView.as_view(), name='manufacturer_add'), + url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), url(r'^manufacturers/(?P[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), # Device types url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), - url(r'^device-types/add/$', views.DeviceTypeEditView.as_view(), name='devicetype_add'), + url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), - url(r'^device-types/(?P\d+)/$', views.devicetype, name='devicetype'), + url(r'^device-types/(?P\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), url(r'^device-types/(?P\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), url(r'^device-types/(?P\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), # Console port templates - url(r'^device-types/(?P\d+)/console-ports/add/$', views.ConsolePortTemplateAddView.as_view(), name='devicetype_add_consoleport'), + url(r'^device-types/(?P\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), url(r'^device-types/(?P\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'), # Console server port templates - url(r'^device-types/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateAddView.as_view(), name='devicetype_add_consoleserverport'), + url(r'^device-types/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'), url(r'^device-types/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'), # Power port templates - url(r'^device-types/(?P\d+)/power-ports/add/$', views.PowerPortTemplateAddView.as_view(), name='devicetype_add_powerport'), + url(r'^device-types/(?P\d+)/power-ports/add/$', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'), url(r'^device-types/(?P\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'), # Power outlet templates - url(r'^device-types/(?P\d+)/power-outlets/add/$', views.PowerOutletTemplateAddView.as_view(), name='devicetype_add_poweroutlet'), + url(r'^device-types/(?P\d+)/power-outlets/add/$', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'), url(r'^device-types/(?P\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'), # Interface templates - url(r'^device-types/(?P\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'), + url(r'^device-types/(?P\d+)/interfaces/add/$', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'), url(r'^device-types/(?P\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), url(r'^device-types/(?P\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), # Device bay templates - url(r'^device-types/(?P\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(), name='devicetype_add_devicebay'), + url(r'^device-types/(?P\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), url(r'^device-types/(?P\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), # Device roles url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'), - url(r'^device-roles/add/$', views.DeviceRoleEditView.as_view(), name='devicerole_add'), + url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), url(r'^device-roles/(?P[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), # Platforms url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), - url(r'^platforms/add/$', views.PlatformEditView.as_view(), name='platform_add'), + url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'), url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), url(r'^platforms/(?P[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), # Devices url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), - url(r'^devices/add/$', views.DeviceEditView.as_view(), name='device_add'), + url(r'^devices/add/$', views.DeviceCreateView.as_view(), name='device_add'), url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'), url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), - url(r'^devices/(?P\d+)/$', views.device, name='device'), + url(r'^devices/(?P\d+)/$', views.DeviceView.as_view(), name='device'), url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), - url(r'^devices/(?P\d+)/inventory/$', views.device_inventory, name='device_inventory'), - url(r'^devices/(?P\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'), - url(r'^devices/(?P\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'), + url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), + url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), + url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), + url(r'^devices/(?P\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), - url(r'^devices/(?P\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'), + url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'), + url(r'^devices/(?P\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'), - url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortAddView.as_view(), name='consoleport_add'), + url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'), url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), url(r'^console-ports/(?P\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'), url(r'^console-ports/(?P\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'), @@ -129,7 +140,8 @@ urlpatterns = [ # Console server ports url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), - url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortAddView.as_view(), name='consoleserverport_add'), + url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), + url(r'^devices/(?P\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), url(r'^console-server-ports/(?P\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'), url(r'^console-server-ports/(?P\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'), @@ -138,7 +150,7 @@ urlpatterns = [ # Power ports url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), - url(r'^devices/(?P\d+)/power-ports/add/$', views.PowerPortAddView.as_view(), name='powerport_add'), + url(r'^devices/(?P\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'), url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), url(r'^power-ports/(?P\d+)/connect/$', views.powerport_connect, name='powerport_connect'), url(r'^power-ports/(?P\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'), @@ -147,7 +159,8 @@ urlpatterns = [ # Power outlets url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), - url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletAddView.as_view(), name='poweroutlet_add'), + url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), + url(r'^devices/(?P\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), url(r'^power-outlets/(?P\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'), url(r'^power-outlets/(?P\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'), @@ -156,8 +169,9 @@ urlpatterns = [ # Interfaces url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), - url(r'^devices/(?P\d+)/interfaces/add/$', views.InterfaceAddView.as_view(), name='interface_add'), + url(r'^devices/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + url(r'^devices/(?P\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), url(r'^devices/(?P\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), @@ -166,13 +180,18 @@ urlpatterns = [ # Device bays url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), - url(r'^devices/(?P\d+)/bays/add/$', views.DeviceBayAddView.as_view(), name='devicebay_add'), + url(r'^devices/(?P\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'), url(r'^devices/(?P\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), url(r'^device-bays/(?P\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), url(r'^device-bays/(?P\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), url(r'^device-bays/(?P\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'), url(r'^device-bays/(?P\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), + # Inventory items + url(r'^devices/(?P\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), + url(r'^inventory-items/(?P\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), + url(r'^inventory-items/(?P\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'), @@ -181,9 +200,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\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'), - url(r'^modules/(?P\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'), - url(r'^modules/(?P\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'), - ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f2b042599..ea07138d5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals from copy import deepcopy import re from natsort import natsorted @@ -6,27 +7,30 @@ 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.db.models import Count +from django.core.paginator import EmptyPage, PageNotAnInteger +from django.db.models import Count, Q from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.html import escape from django.utils.http import urlencode +from django.utils.safestring import mark_safe from django.views.generic import View -from ipam.models import Prefix, IPAddress, Service, VLAN +from ipam.models import Prefix, Service, VLAN from circuits.models import Circuit -from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE +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, ) - 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, - RackReservation, RackRole, Region, Site, + Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, + RackGroup, RackReservation, RackRole, Region, Site, ) @@ -101,12 +105,15 @@ class ComponentCreateView(View): new_components.append(component_form.save(commit=False)) else: for field, errors in component_form.errors.as_data().items(): + # Assign errors on the child form's name field to name_pattern on the parent form + if field == 'name': + field = 'name_pattern' for e in errors: - form.add_error(field, u'{}: {}'.format(name, ', '.join(e))) + form.add_error(field, '{}: {}'.format(name, ', '.join(e))) if not form.errors: self.model.objects.bulk_create(new_components) - messages.success(request, u"Added {} {} to {}.".format( + messages.success(request, "Added {} {} to {}.".format( len(new_components), self.model._meta.verbose_name_plural, parent )) if '_addanother' in request.POST: @@ -124,16 +131,54 @@ class ComponentCreateView(View): class ComponentEditView(ObjectEditView): - def get_return_url(self, obj): + def get_return_url(self, request, obj): return obj.device.get_absolute_url() class ComponentDeleteView(ObjectDeleteView): - def get_return_url(self, obj): + def get_return_url(self, request, obj): return obj.device.get_absolute_url() +class BulkDisconnectView(View): + """ + An extendable view for disconnection console/power/interface components in bulk. + """ + model = None + form = None + template_name = 'dcim/bulk_disconnect.html' + + def disconnect_objects(self, objects): + raise NotImplementedError() + + def post(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + selected_objects = [] + + if '_confirm' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + count = self.disconnect_objects(form.cleaned_data['pk']) + messages.success(request, "Disconnected {} {} on {}".format( + count, self.model._meta.verbose_name_plural, device + )) + return redirect(device.get_absolute_url()) + + else: + form = self.form(initial={'pk': request.POST.getlist('pk')}) + selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) + + return render(request, self.template_name, { + 'form': form, + 'device': device, + 'obj_type_plural': self.model._meta.verbose_name_plural, + 'selected_objects': selected_objects, + 'return_url': device.get_absolute_url(), + }) + + # # Regions # @@ -144,18 +189,24 @@ class RegionListView(ObjectListView): template_name = 'dcim/region_list.html' -class RegionEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_region' +class RegionCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_region' model = Region form_class = forms.RegionForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('dcim:region_list') +class RegionEditView(RegionCreateView): + permission_required = 'dcim.change_region' + + class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_region' cls = Region + queryset = Region.objects.annotate(site_count=Count('sites')) + table = tables.RegionTable default_return_url = 'dcim:region_list' @@ -167,41 +218,47 @@ class SiteListView(ObjectListView): queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm - table = tables.SiteTable + table = tables.SiteDetailTable template_name = 'dcim/site_list.html' -def site(request, slug): +class SiteView(View): - site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug) - stats = { - 'rack_count': Rack.objects.filter(site=site).count(), - 'device_count': Device.objects.filter(site=site).count(), - 'prefix_count': Prefix.objects.filter(site=site).count(), - 'vlan_count': VLAN.objects.filter(site=site).count(), - 'circuit_count': Circuit.objects.filter(terminations__site=site).count(), - } - rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks')) - topology_maps = TopologyMap.objects.filter(site=site) - show_graphs = Graph.objects.filter(type=GRAPH_TYPE_SITE).exists() + def get(self, request, slug): - return render(request, 'dcim/site.html', { - 'site': site, - 'stats': stats, - 'rack_groups': rack_groups, - 'topology_maps': topology_maps, - 'show_graphs': show_graphs, - }) + site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug) + stats = { + 'rack_count': Rack.objects.filter(site=site).count(), + 'device_count': Device.objects.filter(site=site).count(), + 'prefix_count': Prefix.objects.filter(site=site).count(), + 'vlan_count': VLAN.objects.filter(site=site).count(), + 'circuit_count': Circuit.objects.filter(terminations__site=site).count(), + } + rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks')) + topology_maps = TopologyMap.objects.filter(site=site) + show_graphs = Graph.objects.filter(type=GRAPH_TYPE_SITE).exists() + + return render(request, 'dcim/site.html', { + 'site': site, + 'stats': stats, + 'rack_groups': rack_groups, + 'topology_maps': topology_maps, + 'show_graphs': show_graphs, + }) -class SiteEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_site' +class SiteCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_site' model = Site form_class = forms.SiteForm template_name = 'dcim/site_edit.html' default_return_url = 'dcim:site_list' +class SiteEditView(SiteCreateView): + permission_required = 'dcim.change_site' + + class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_site' model = Site @@ -210,18 +267,18 @@ class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_site' - form = forms.SiteImportForm + model_form = forms.SiteCSVForm table = tables.SiteTable - template_name = 'dcim/site_import.html' default_return_url = 'dcim:site_list' class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_site' cls = Site + queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter + table = tables.SiteTable form = forms.SiteBulkEditForm - template_name = 'dcim/site_bulk_edit.html' default_return_url = 'dcim:site_list' @@ -237,19 +294,25 @@ class RackGroupListView(ObjectListView): template_name = 'dcim/rackgroup_list.html' -class RackGroupEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_rackgroup' +class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_rackgroup' model = RackGroup form_class = forms.RackGroupForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('dcim:rackgroup_list') +class RackGroupEditView(RackGroupCreateView): + permission_required = 'dcim.change_rackgroup' + + class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackgroup' cls = RackGroup + queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) filter = filters.RackGroupFilter + table = tables.RackGroupTable default_return_url = 'dcim:rackgroup_list' @@ -263,18 +326,24 @@ class RackRoleListView(ObjectListView): template_name = 'dcim/rackrole_list.html' -class RackRoleEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_rackrole' +class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_rackrole' model = RackRole form_class = forms.RackRoleForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('dcim:rackrole_list') +class RackRoleEditView(RackRoleCreateView): + permission_required = 'dcim.change_rackrole' + + class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackrole' cls = RackRole + queryset = RackRole.objects.annotate(rack_count=Count('racks')) + table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' @@ -283,49 +352,100 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class RackListView(ObjectListView): - queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\ - .annotate(device_count=Count('devices', distinct=True)) + queryset = Rack.objects.select_related( + 'site', 'group', 'tenant', 'role' + ).prefetch_related( + 'devices__device_type' + ).annotate( + device_count=Count('devices', distinct=True) + ) filter = filters.RackFilter filter_form = forms.RackFilterForm - table = tables.RackTable + table = tables.RackDetailTable template_name = 'dcim/rack_list.html' -def rack(request, pk): +class RackElevationListView(View): + """ + Display a set of rack elevations side-by-side. + """ - rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) + def get(self, request): - nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\ - .select_related('device_type__manufacturer') - next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first() - prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() + 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() - reservations = RackReservation.objects.filter(rack=rack) - reserved_units = {} - for r in reservations: - for u in r.units: - reserved_units[u] = r + # 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) - return render(request, 'dcim/rack.html', { - 'rack': rack, - 'reservations': reservations, - 'reserved_units': reserved_units, - 'nonracked_devices': nonracked_devices, - 'next_rack': next_rack, - 'prev_rack': prev_rack, - 'front_elevation': rack.get_front_elevation(), - 'rear_elevation': rack.get_rear_elevation(), - }) + # 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), + }) -class RackEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_rack' +class RackView(View): + + def get(self, request, pk): + + rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) + + nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\ + .select_related('device_type__manufacturer') + next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first() + prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() + + reservations = RackReservation.objects.filter(rack=rack) + reserved_units = {} + for r in reservations: + for u in r.units: + reserved_units[u] = r + + return render(request, 'dcim/rack.html', { + 'rack': rack, + 'reservations': reservations, + 'reserved_units': reserved_units, + 'nonracked_devices': nonracked_devices, + 'next_rack': next_rack, + 'prev_rack': prev_rack, + 'front_elevation': rack.get_front_elevation(), + 'rear_elevation': rack.get_rear_elevation(), + }) + + +class RackCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_rack' model = Rack form_class = forms.RackForm template_name = 'dcim/rack_edit.html' default_return_url = 'dcim:rack_list' +class RackEditView(RackCreateView): + permission_required = 'dcim.change_rack' + + class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_rack' model = Rack @@ -334,25 +454,27 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_rack' - form = forms.RackImportForm + model_form = forms.RackCSVForm table = tables.RackImportTable - template_name = 'dcim/rack_import.html' default_return_url = 'dcim:rack_list' class RackBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rack' cls = Rack + queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter + table = tables.RackTable form = forms.RackBulkEditForm - template_name = 'dcim/rack_bulk_edit.html' default_return_url = 'dcim:rack_list' class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rack' cls = Rack + queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter + table = tables.RackTable default_return_url = 'dcim:rack_list' @@ -360,8 +482,16 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack reservations # -class RackReservationEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_rackreservation' +class RackReservationListView(ObjectListView): + queryset = RackReservation.objects.all() + filter = filters.RackReservationFilter + filter_form = forms.RackReservationFilterForm + table = tables.RackReservationTable + template_name = 'dcim/rackreservation_list.html' + + +class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_rackreservation' model = RackReservation form_class = forms.RackReservationForm @@ -371,18 +501,29 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView): obj.user = request.user return obj - def get_return_url(self, obj): + def get_return_url(self, request, obj): return obj.rack.get_absolute_url() +class RackReservationEditView(RackReservationCreateView): + permission_required = 'dcim.change_rackreservation' + + class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_rackreservation' model = RackReservation - def get_return_url(self, obj): + def get_return_url(self, request, obj): return obj.rack.get_absolute_url() +class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_rackreservation' + cls = RackReservation + table = tables.RackReservationTable + default_return_url = 'dcim:rackreservation_list' + + # # Manufacturers # @@ -393,18 +534,24 @@ class ManufacturerListView(ObjectListView): template_name = 'dcim/manufacturer_list.html' -class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_manufacturer' +class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_manufacturer' model = Manufacturer form_class = forms.ManufacturerForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('dcim:manufacturer_list') +class ManufacturerEditView(ManufacturerCreateView): + permission_required = 'dcim.change_manufacturer' + + class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_manufacturer' cls = Manufacturer + queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) + table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' @@ -420,63 +567,70 @@ class DeviceTypeListView(ObjectListView): template_name = 'dcim/devicetype_list.html' -def devicetype(request, pk): +class DeviceTypeView(View): - devicetype = get_object_or_404(DeviceType, pk=pk) + def get(self, request, pk): - # Component tables - consoleport_table = tables.ConsolePortTemplateTable( - natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) - ) - consoleserverport_table = tables.ConsoleServerPortTemplateTable( - natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) - ) - powerport_table = tables.PowerPortTemplateTable( - natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) - ) - poweroutlet_table = tables.PowerOutletTemplateTable( - natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) - ) - mgmt_interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype, - mgmt_only=True)) - ) - interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype, - mgmt_only=False)) - ) - devicebay_table = tables.DeviceBayTemplateTable( - natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) - ) - if request.user.has_perm('dcim.change_devicetype'): - consoleport_table.base_columns['pk'].visible = True - consoleserverport_table.base_columns['pk'].visible = True - powerport_table.base_columns['pk'].visible = True - poweroutlet_table.base_columns['pk'].visible = True - mgmt_interface_table.base_columns['pk'].visible = True - interface_table.base_columns['pk'].visible = True - devicebay_table.base_columns['pk'].visible = True + devicetype = get_object_or_404(DeviceType, pk=pk) - return render(request, 'dcim/devicetype.html', { - 'devicetype': devicetype, - 'consoleport_table': consoleport_table, - 'consoleserverport_table': consoleserverport_table, - 'powerport_table': powerport_table, - 'poweroutlet_table': poweroutlet_table, - 'mgmt_interface_table': mgmt_interface_table, - 'interface_table': interface_table, - 'devicebay_table': devicebay_table, - }) + # Component tables + consoleport_table = tables.ConsolePortTemplateTable( + natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False + ) + consoleserverport_table = tables.ConsoleServerPortTemplateTable( + natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False + ) + powerport_table = tables.PowerPortTemplateTable( + natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False + ) + poweroutlet_table = tables.PowerOutletTemplateTable( + natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False + ) + interface_table = tables.InterfaceTemplateTable( + list(InterfaceTemplate.objects.order_naturally( + devicetype.interface_ordering + ).filter(device_type=devicetype)), + show_header=False + ) + devicebay_table = tables.DeviceBayTemplateTable( + natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False + ) + if request.user.has_perm('dcim.change_devicetype'): + consoleport_table.base_columns['pk'].visible = True + consoleserverport_table.base_columns['pk'].visible = True + powerport_table.base_columns['pk'].visible = True + poweroutlet_table.base_columns['pk'].visible = True + interface_table.base_columns['pk'].visible = True + devicebay_table.base_columns['pk'].visible = True + + return render(request, 'dcim/devicetype.html', { + 'devicetype': devicetype, + 'consoleport_table': consoleport_table, + 'consoleserverport_table': consoleserverport_table, + 'powerport_table': powerport_table, + 'poweroutlet_table': poweroutlet_table, + 'interface_table': interface_table, + 'devicebay_table': devicebay_table, + }) -class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_devicetype' +class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_devicetype' model = DeviceType form_class = forms.DeviceTypeForm template_name = 'dcim/devicetype_edit.html' default_return_url = 'dcim:devicetype_list' +class DeviceTypeEditView(DeviceTypeCreateView): + permission_required = 'dcim.change_devicetype' + + class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicetype' model = DeviceType @@ -486,16 +640,19 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' cls = DeviceType + queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter + table = tables.DeviceTypeTable form = forms.DeviceTypeBulkEditForm - template_name = 'dcim/devicetype_bulk_edit.html' default_return_url = 'dcim:devicetype_list' class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicetype' cls = DeviceType + queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter + table = tables.DeviceTypeTable default_return_url = 'dcim:devicetype_list' @@ -503,7 +660,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device type components # -class ConsolePortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): +class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleporttemplate' parent_model = DeviceType parent_field = 'device_type' @@ -518,9 +675,10 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) parent_field = 'device_type' cls = ConsolePortTemplate parent_cls = DeviceType + table = tables.ConsolePortTemplateTable -class ConsoleServerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): +class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverporttemplate' parent_model = DeviceType parent_field = 'device_type' @@ -533,9 +691,10 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet permission_required = 'dcim.delete_consoleserverporttemplate' cls = ConsoleServerPortTemplate parent_cls = DeviceType + table = tables.ConsoleServerPortTemplateTable -class PowerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): +class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerporttemplate' parent_model = DeviceType parent_field = 'device_type' @@ -548,9 +707,10 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerporttemplate' cls = PowerPortTemplate parent_cls = DeviceType + table = tables.PowerPortTemplateTable -class PowerOutletTemplateAddView(PermissionRequiredMixin, ComponentCreateView): +class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlettemplate' parent_model = DeviceType parent_field = 'device_type' @@ -563,9 +723,10 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) permission_required = 'dcim.delete_poweroutlettemplate' cls = PowerOutletTemplate parent_cls = DeviceType + table = tables.PowerOutletTemplateTable -class InterfaceTemplateAddView(PermissionRequiredMixin, ComponentCreateView): +class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interfacetemplate' parent_model = DeviceType parent_field = 'device_type' @@ -578,17 +739,18 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interfacetemplate' cls = InterfaceTemplate parent_cls = DeviceType + table = tables.InterfaceTemplateTable form = forms.InterfaceTemplateBulkEditForm - template_name = 'dcim/interfacetemplate_bulk_edit.html' class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interfacetemplate' cls = InterfaceTemplate parent_cls = DeviceType + table = tables.InterfaceTemplateTable -class DeviceBayTemplateAddView(PermissionRequiredMixin, ComponentCreateView): +class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebaytemplate' parent_model = DeviceType parent_field = 'device_type' @@ -601,6 +763,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebaytemplate' cls = DeviceBayTemplate parent_cls = DeviceType + table = tables.DeviceBayTemplateTable # @@ -613,18 +776,24 @@ class DeviceRoleListView(ObjectListView): template_name = 'dcim/devicerole_list.html' -class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_devicerole' +class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_devicerole' model = DeviceRole form_class = forms.DeviceRoleForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('dcim:devicerole_list') +class DeviceRoleEditView(DeviceRoleCreateView): + permission_required = 'dcim.change_devicerole' + + class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicerole' cls = DeviceRole + queryset = DeviceRole.objects.annotate(device_count=Count('devices')) + table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' @@ -638,18 +807,24 @@ class PlatformListView(ObjectListView): template_name = 'dcim/platform_list.html' -class PlatformEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_platform' +class PlatformCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_platform' model = Platform form_class = forms.PlatformForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('dcim:platform_list') +class PlatformEditView(PlatformCreateView): + permission_required = 'dcim.change_platform' + + class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_platform' cls = Platform + queryset = Platform.objects.annotate(device_count=Count('devices')) + table = tables.PlatformTable default_return_url = 'dcim:platform_list' @@ -662,90 +837,146 @@ class DeviceListView(ObjectListView): 'primary_ip4', 'primary_ip6') filter = filters.DeviceFilter filter_form = forms.DeviceFilterForm - table = tables.DeviceTable + table = tables.DeviceDetailTable template_name = 'dcim/device_list.html' -def device(request, pk): +class DeviceView(View): - device = get_object_or_404(Device.objects.select_related( - 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' - ), pk=pk) - console_ports = natsorted( - ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') - ) - cs_ports = natsorted( - ConsoleServerPort.objects.filter(device=device).select_related('connected_console'), key=attrgetter('name') - ) - power_ports = natsorted( - PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') - ) - power_outlets = natsorted( - PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name') - ) - interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\ - .filter(device=device, mgmt_only=False)\ - .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', - 'circuit_termination__circuit') - mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\ - .filter(device=device, mgmt_only=True)\ - .select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', - 'circuit_termination__circuit') - device_bays = natsorted( - DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), - key=attrgetter('name') - ) + def get(self, request, pk): - # Gather relevant device objects - ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\ - .order_by('address') - services = Service.objects.filter(device=device) - secrets = device.secrets.all() + device = get_object_or_404(Device.objects.select_related( + 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' + ), pk=pk) + console_ports = natsorted( + ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') + ) + cs_ports = natsorted( + ConsoleServerPort.objects.filter(device=device).select_related('connected_console'), key=attrgetter('name') + ) + power_ports = natsorted( + PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') + ) + power_outlets = natsorted( + PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name') + ) + interfaces = Interface.objects.order_naturally( + device.device_type.interface_ordering + ).filter( + device=device + ).select_related( + 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', + 'circuit_termination__circuit' + ).prefetch_related('ip_addresses') + device_bays = natsorted( + DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), + key=attrgetter('name') + ) + services = Service.objects.filter(device=device) + secrets = device.secrets.all() - # Find any related devices for convenient linking in the UI - related_devices = [] - if device.name: - if re.match('.+[0-9]+$', device.name): - # Strip 1 or more trailing digits (e.g. core-switch1) - base_name = re.match('(.*?)[0-9]+$', device.name).group(1) - elif re.match('.+\d[a-z]$', device.name.lower()): - # Strip a trailing letter if preceded by a digit (e.g. dist-switch3a -> dist-switch3) - base_name = re.match('(.*\d+)[a-z]$', device.name.lower()).group(1) - else: - base_name = None - if base_name: - related_devices = Device.objects.filter(name__istartswith=base_name).exclude(pk=device.pk)\ - .select_related('rack', 'device_type__manufacturer')[:10] + # Find up to ten devices in the same site with the same functional role for quick reference. + related_devices = Device.objects.filter( + site=device.site, device_role=device.device_role + ).exclude( + pk=device.pk + ).select_related( + 'rack', 'device_type__manufacturer' + )[:10] - # Show graph button on interfaces only if at least one graph has been created. - show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists() + # Show graph button on interfaces only if at least one graph has been created. + show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists() - return render(request, 'dcim/device.html', { - 'device': device, - 'console_ports': console_ports, - 'cs_ports': cs_ports, - 'power_ports': power_ports, - 'power_outlets': power_outlets, - 'interfaces': interfaces, - 'mgmt_interfaces': mgmt_interfaces, - 'device_bays': device_bays, - 'ip_addresses': ip_addresses, - 'services': services, - 'secrets': secrets, - 'related_devices': related_devices, - 'show_graphs': show_graphs, - }) + return render(request, 'dcim/device.html', { + 'device': device, + 'console_ports': console_ports, + 'cs_ports': cs_ports, + 'power_ports': power_ports, + 'power_outlets': power_outlets, + 'interfaces': interfaces, + 'device_bays': device_bays, + 'services': services, + 'secrets': secrets, + 'related_devices': related_devices, + 'show_graphs': show_graphs, + }) -class DeviceEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_device' +class DeviceInventoryView(View): + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + 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, + 'inventory_items': inventory_items, + }) + + +class DeviceStatusView(PermissionRequiredMixin, View): + permission_required = 'dcim.napalm_read' + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + + return render(request, 'dcim/device_status.html', { + 'device': device, + }) + + +class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): + permission_required = 'dcim.napalm_read' + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + interfaces = Interface.objects.order_naturally( + device.device_type.interface_ordering + ).filter( + device=device + ).select_related( + 'connected_as_a', 'connected_as_b' + ) + + return render(request, 'dcim/device_lldp_neighbors.html', { + 'device': device, + 'interfaces': interfaces, + }) + + +class DeviceConfigView(PermissionRequiredMixin, View): + permission_required = 'dcim.napalm_read' + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + + return render(request, 'dcim/device_config.html', { + 'device': device, + }) + + +class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_device' model = Device form_class = forms.DeviceForm - fields_initial = ['site', 'rack', 'position', 'face', 'device_bay'] template_name = 'dcim/device_edit.html' default_return_url = 'dcim:device_list' +class DeviceEditView(DeviceCreateView): + permission_required = 'dcim.change_device' + + class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_device' model = Device @@ -754,7 +985,7 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_device' - form = forms.DeviceImportForm + model_form = forms.DeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import.html' default_return_url = 'dcim:device_list' @@ -762,69 +993,47 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.add_device' - form = forms.ChildDeviceImportForm + model_form = forms.ChildDeviceCSVForm table = tables.DeviceImportTable template_name = 'dcim/device_import_child.html' default_return_url = 'dcim:device_list' - def save_obj(self, obj): + def _save_obj(self, obj_form): - # Inherit site and rack from parent device - obj.site = obj.parent_bay.device.site - obj.rack = obj.parent_bay.device.rack - obj.save() + obj = obj_form.save() - # Save the reverse relation + # Save the reverse relation to the parent device bay device_bay = obj.parent_bay device_bay.installed_device = obj device_bay.save() + return obj + class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_device' cls = Device + queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter + table = tables.DeviceTable form = forms.DeviceBulkEditForm - template_name = 'dcim/device_bulk_edit.html' default_return_url = 'dcim:device_list' class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_device' cls = Device + queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter + table = tables.DeviceTable default_return_url = 'dcim:device_list' -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') - - return render(request, 'dcim/device_inventory.html', { - 'device': device, - 'modules': modules, - }) - - -def device_lldp_neighbors(request, pk): - - device = get_object_or_404(Device, pk=pk) - interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(device=device)\ - .select_related('connected_as_a', 'connected_as_b') - - return render(request, 'dcim/device_lldp_neighbors.html', { - 'device': device, - 'interfaces': interfaces, - }) - - # # Console ports # -class ConsolePortAddView(PermissionRequiredMixin, ComponentCreateView): +class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleport' parent_model = Device parent_field = 'device' @@ -842,19 +1051,23 @@ def consoleport_connect(request, pk): form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport) if form.is_valid(): consoleport = form.save() - messages.success(request, u"Connected {} {} to {} {}.".format( - consoleport.device, - consoleport.name, - consoleport.cs_port.device, - consoleport.cs_port.name, - )) + msg = 'Connected {} {} to {} {}'.format( + consoleport.device.get_absolute_url(), + escape(consoleport.device), + escape(consoleport.name), + consoleport.cs_port.device.get_absolute_url(), + escape(consoleport.cs_port.device), + escape(consoleport.cs_port.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleport.device.pk) else: form = forms.ConsolePortConnectionForm(instance=consoleport, initial={ - 'site': request.GET.get('site', consoleport.device.site), - 'rack': request.GET.get('rack', None), - 'console_server': request.GET.get('console_server', None), + 'site': request.GET.get('site'), + 'rack': request.GET.get('rack'), + 'console_server': request.GET.get('console_server'), 'connection_status': CONNECTION_STATUS_CONNECTED, }) @@ -871,17 +1084,28 @@ def consoleport_disconnect(request, pk): consoleport = get_object_or_404(ConsolePort, pk=pk) if not consoleport.cs_port: - messages.warning(request, u"Cannot disconnect console port {}: It is not connected to anything." - .format(consoleport)) + messages.warning( + request, "Cannot disconnect console port {}: It is not connected to anything.".format(consoleport) + ) return redirect('dcim:device', pk=consoleport.device.pk) if request.method == 'POST': form = ConfirmationForm(request.POST) if form.is_valid(): + cs_port = consoleport.cs_port consoleport.cs_port = None consoleport.connection_status = None consoleport.save() - messages.success(request, u"Console port {} has been disconnected.".format(consoleport)) + msg = 'Disconnected {} {} from {} {}'.format( + consoleport.device.get_absolute_url(), + escape(consoleport.device), + escape(consoleport.name), + cs_port.device.get_absolute_url(), + escape(cs_port.device), + escape(cs_port.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleport.device.pk) else: @@ -909,20 +1133,21 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleport' cls = ConsolePort parent_cls = Device + table = tables.ConsolePortTable class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.change_consoleport' - form = forms.ConsoleConnectionImportForm + model_form = forms.ConsoleConnectionCSVForm table = tables.ConsoleConnectionTable - template_name = 'dcim/console_connections_import.html' + default_return_url = 'dcim:console_connections_list' # # Console server ports # -class ConsoleServerPortAddView(PermissionRequiredMixin, ComponentCreateView): +class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverport' parent_model = Device parent_field = 'device' @@ -943,19 +1168,23 @@ def consoleserverport_connect(request, pk): consoleport.cs_port = consoleserverport consoleport.connection_status = form.cleaned_data['connection_status'] consoleport.save() - messages.success(request, u"Connected {} {} to {} {}.".format( - consoleport.device, - consoleport.name, - consoleserverport.device, - consoleserverport.name, - )) + msg = 'Connected {} {} to {} {}'.format( + consoleport.device.get_absolute_url(), + escape(consoleport.device), + escape(consoleport.name), + consoleserverport.device.get_absolute_url(), + escape(consoleserverport.device), + escape(consoleserverport.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleserverport.device.pk) else: form = forms.ConsoleServerPortConnectionForm(initial={ - 'site': request.GET.get('site', consoleserverport.device.site), - 'rack': request.GET.get('rack', None), - 'device': request.GET.get('device', None), + 'site': request.GET.get('site'), + 'rack': request.GET.get('rack'), + 'device': request.GET.get('device'), 'connection_status': CONNECTION_STATUS_CONNECTED, }) @@ -972,8 +1201,9 @@ def consoleserverport_disconnect(request, pk): consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) if not hasattr(consoleserverport, 'connected_console'): - messages.warning(request, u"Cannot disconnect console server port {}: Nothing is connected to it." - .format(consoleserverport)) + messages.warning( + request, "Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport) + ) return redirect('dcim:device', pk=consoleserverport.device.pk) if request.method == 'POST': @@ -983,7 +1213,16 @@ def consoleserverport_disconnect(request, pk): consoleport.cs_port = None consoleport.connection_status = None consoleport.save() - messages.success(request, u"Console server port {} has been disconnected.".format(consoleserverport)) + msg = 'Disconnected {} {} from {} {}'.format( + consoleport.device.get_absolute_url(), + escape(consoleport.device), + escape(consoleport.name), + consoleserverport.device.get_absolute_url(), + escape(consoleserverport.device), + escape(consoleserverport.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, consoleport, msg) return redirect('dcim:device', pk=consoleserverport.device.pk) else: @@ -1007,17 +1246,27 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): model = ConsoleServerPort +class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_consoleserverport' + model = ConsoleServerPort + form = forms.ConsoleServerPortBulkDisconnectForm + + def disconnect_objects(self, cs_ports): + return ConsolePort.objects.filter(cs_port__in=cs_ports).update(cs_port=None, connection_status=None) + + class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' cls = ConsoleServerPort parent_cls = Device + table = tables.ConsoleServerPortTable # # Power ports # -class PowerPortAddView(PermissionRequiredMixin, ComponentCreateView): +class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerport' parent_model = Device parent_field = 'device' @@ -1035,19 +1284,23 @@ def powerport_connect(request, pk): form = forms.PowerPortConnectionForm(request.POST, instance=powerport) if form.is_valid(): powerport = form.save() - messages.success(request, u"Connected {} {} to {} {}.".format( - powerport.device, - powerport.name, - powerport.power_outlet.device, - powerport.power_outlet.name, - )) + msg = 'Connected {} {} to {} {}'.format( + powerport.device.get_absolute_url(), + escape(powerport.device), + escape(powerport.name), + powerport.power_outlet.device.get_absolute_url(), + escape(powerport.power_outlet.device), + escape(powerport.power_outlet.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=powerport.device.pk) else: form = forms.PowerPortConnectionForm(instance=powerport, initial={ - 'site': request.GET.get('site', powerport.device.site), - 'rack': request.GET.get('rack', None), - 'pdu': request.GET.get('pdu', None), + 'site': request.GET.get('site'), + 'rack': request.GET.get('rack'), + 'pdu': request.GET.get('pdu'), 'connection_status': CONNECTION_STATUS_CONNECTED, }) @@ -1064,17 +1317,28 @@ def powerport_disconnect(request, pk): powerport = get_object_or_404(PowerPort, pk=pk) if not powerport.power_outlet: - messages.warning(request, u"Cannot disconnect power port {}: It is not connected to an outlet." - .format(powerport)) + messages.warning( + request, "Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport) + ) return redirect('dcim:device', pk=powerport.device.pk) if request.method == 'POST': form = ConfirmationForm(request.POST) if form.is_valid(): + power_outlet = powerport.power_outlet powerport.power_outlet = None powerport.connection_status = None powerport.save() - messages.success(request, u"Power port {} has been disconnected.".format(powerport)) + msg = 'Disconnected {} {} from {} {}'.format( + powerport.device.get_absolute_url(), + escape(powerport.device), + escape(powerport.name), + power_outlet.device.get_absolute_url(), + escape(power_outlet.device), + escape(power_outlet.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=powerport.device.pk) else: @@ -1102,20 +1366,21 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' cls = PowerPort parent_cls = Device + table = tables.PowerPortTable class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.change_powerport' - form = forms.PowerConnectionImportForm + model_form = forms.PowerConnectionCSVForm table = tables.PowerConnectionTable - template_name = 'dcim/power_connections_import.html' + default_return_url = 'dcim:power_connections_list' # # Power outlets # -class PowerOutletAddView(PermissionRequiredMixin, ComponentCreateView): +class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlet' parent_model = Device parent_field = 'device' @@ -1136,19 +1401,23 @@ def poweroutlet_connect(request, pk): powerport.power_outlet = poweroutlet powerport.connection_status = form.cleaned_data['connection_status'] powerport.save() - messages.success(request, u"Connected {} {} to {} {}.".format( - powerport.device, - powerport.name, - poweroutlet.device, - poweroutlet.name, - )) + msg = 'Connected {} {} to {} {}'.format( + powerport.device.get_absolute_url(), + escape(powerport.device), + escape(powerport.name), + poweroutlet.device.get_absolute_url(), + escape(poweroutlet.device), + escape(poweroutlet.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=poweroutlet.device.pk) else: form = forms.PowerOutletConnectionForm(initial={ - 'site': request.GET.get('site', poweroutlet.device.site), - 'rack': request.GET.get('rack', None), - 'device': request.GET.get('device', None), + 'site': request.GET.get('site'), + 'rack': request.GET.get('rack'), + 'device': request.GET.get('device'), 'connection_status': CONNECTION_STATUS_CONNECTED, }) @@ -1165,7 +1434,9 @@ def poweroutlet_disconnect(request, pk): poweroutlet = get_object_or_404(PowerOutlet, pk=pk) if not hasattr(poweroutlet, 'connected_port'): - messages.warning(request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet)) + messages.warning( + request, "Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet) + ) return redirect('dcim:device', pk=poweroutlet.device.pk) if request.method == 'POST': @@ -1175,7 +1446,16 @@ def poweroutlet_disconnect(request, pk): powerport.power_outlet = None powerport.connection_status = None powerport.save() - messages.success(request, u"Power outlet {} has been disconnected.".format(poweroutlet)) + msg = 'Disconnected {} {} from {} {}'.format( + powerport.device.get_absolute_url(), + escape(powerport.device), + escape(powerport.name), + poweroutlet.device.get_absolute_url(), + escape(poweroutlet.device), + escape(poweroutlet.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, powerport, msg) return redirect('dcim:device', pk=poweroutlet.device.pk) else: @@ -1199,17 +1479,29 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): model = PowerOutlet +class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_poweroutlet' + model = PowerOutlet + form = forms.PowerOutletBulkDisconnectForm + + def disconnect_objects(self, power_outlets): + return PowerPort.objects.filter(power_outlet__in=power_outlets).update( + power_outlet=None, connection_status=None + ) + + class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' cls = PowerOutlet parent_cls = Device + table = tables.PowerOutletTable # # Interfaces # -class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView): +class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interface' parent_model = Device parent_field = 'device' @@ -1229,25 +1521,38 @@ class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): model = Interface +class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): + permission_required = 'dcim.change_interface' + model = Interface + form = forms.InterfaceBulkDisconnectForm + + def disconnect_objects(self, interfaces): + count, _ = InterfaceConnection.objects.filter( + Q(interface_a__in=interfaces) | Q(interface_b__in=interfaces) + ).delete() + return count + + class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' cls = Interface parent_cls = Device + table = tables.InterfaceTable form = forms.InterfaceBulkEditForm - template_name = 'dcim/interface_bulk_edit.html' class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' cls = Interface parent_cls = Device + table = tables.InterfaceTable # # Device bays # -class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView): +class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebay' parent_model = Device parent_field = 'device' @@ -1280,7 +1585,7 @@ def devicebay_populate(request, pk): device_bay.save() if not form.errors: - messages.success(request, u"Added {} to {}.".format(device_bay.installed_device, device_bay)) + messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay)) return redirect('dcim:device', pk=device_bay.device.pk) else: @@ -1304,7 +1609,7 @@ def devicebay_depopulate(request, pk): removed_device = device_bay.installed_device device_bay.installed_device = None device_bay.save() - messages.success(request, u"{} has been removed from {}.".format(removed_device, device_bay)) + messages.success(request, "{} has been removed from {}.".format(removed_device, device_bay)) return redirect('dcim:device', pk=device_bay.device.pk) else: @@ -1321,6 +1626,7 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebay' cls = DeviceBay parent_cls = Device + table = tables.DeviceBayTable # @@ -1367,11 +1673,11 @@ class DeviceBulkAddComponentView(View): else: for field, errors in component_form.errors.as_data().items(): for e in errors: - form.add_error(field, u'{} {}: {}'.format(device, name, ', '.join(e))) + form.add_error(field, '{} {}: {}'.format(device, name, ', '.join(e))) if not form.errors: self.model.objects.bulk_create(new_components) - messages.success(request, u"Added {} {} to {} devices.".format( + messages.success(request, "Added {} {} to {} devices.".format( len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk']) )) return redirect('dcim:device_list') @@ -1381,7 +1687,7 @@ class DeviceBulkAddComponentView(View): selected_devices = Device.objects.filter(pk__in=pk_list) if not selected_devices: - messages.warning(request, u"No devices were selected.") + messages.warning(request, "No devices were selected.") return redirect('dcim:device_list') return render(request, 'dcim/device_bulk_add_component.html', { @@ -1441,13 +1747,19 @@ def interfaceconnection_add(request, pk): if request.method == 'POST': form = forms.InterfaceConnectionForm(device, request.POST) if form.is_valid(): + interfaceconnection = form.save() - messages.success(request, u"Connected {} {} to {} {}.".format( - interfaceconnection.interface_a.device, - interfaceconnection.interface_a, - interfaceconnection.interface_b.device, - interfaceconnection.interface_b, - )) + msg = 'Connected {} {} to {} {}'.format( + interfaceconnection.interface_a.device.get_absolute_url(), + escape(interfaceconnection.interface_a.device), + escape(interfaceconnection.interface_a.name), + interfaceconnection.interface_b.device.get_absolute_url(), + escape(interfaceconnection.interface_b.device), + escape(interfaceconnection.interface_b.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, interfaceconnection, msg) + if '_addanother' in request.POST: base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk}) device_b = interfaceconnection.interface_b.device @@ -1461,11 +1773,11 @@ def interfaceconnection_add(request, pk): else: form = forms.InterfaceConnectionForm(device, initial={ - 'interface_a': request.GET.get('interface_a', None), - 'site_b': request.GET.get('site_b', device.site), - 'rack_b': request.GET.get('rack_b', None), - 'device_b': request.GET.get('device_b', None), - 'interface_b': request.GET.get('interface_b', None), + 'interface_a': request.GET.get('interface_a'), + 'site_b': request.GET.get('site_b'), + 'rack_b': request.GET.get('rack_b'), + 'device_b': request.GET.get('device_b'), + 'interface_b': request.GET.get('interface_b'), }) return render(request, 'dcim/interfaceconnection_edit.html', { @@ -1485,12 +1797,16 @@ def interfaceconnection_delete(request, pk): form = forms.InterfaceConnectionDeletionForm(request.POST) if form.is_valid(): interfaceconnection.delete() - messages.success(request, u"Deleted the connection between {} {} and {} {}.".format( - interfaceconnection.interface_a.device, - interfaceconnection.interface_a, - interfaceconnection.interface_b.device, - interfaceconnection.interface_b, - )) + msg = 'Disconnected {} {} from {} {}'.format( + interfaceconnection.interface_a.device.get_absolute_url(), + escape(interfaceconnection.interface_a.device), + escape(interfaceconnection.interface_a.name), + interfaceconnection.interface_b.device.get_absolute_url(), + escape(interfaceconnection.interface_b.device), + escape(interfaceconnection.interface_b.name), + ) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, interfaceconnection, msg) if form.cleaned_data['device']: return redirect('dcim:device', pk=form.cleaned_data['device'].pk) else: @@ -1517,9 +1833,9 @@ def interfaceconnection_delete(request, pk): class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.change_interface' - form = forms.InterfaceConnectionImportForm + model_form = forms.InterfaceConnectionCSVForm table = tables.InterfaceConnectionTable - template_name = 'dcim/interface_connections_import.html' + default_return_url = 'dcim:interface_connections_list' # @@ -1554,54 +1870,13 @@ class InterfaceConnectionsListView(ObjectListView): # -# IP addresses +# Inventory items # -@permission_required(['dcim.change_device', 'ipam.add_ipaddress']) -def ipaddress_assign(request, pk): - - device = get_object_or_404(Device, pk=pk) - - if request.method == 'POST': - form = forms.IPAddressForm(device, request.POST) - if form.is_valid(): - - ipaddress = form.save(commit=False) - ipaddress.interface = form.cleaned_data['interface'] - ipaddress.save() - form.save_custom_fields() - messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface)) - - if form.cleaned_data['set_as_primary']: - if ipaddress.family == 4: - device.primary_ip4 = ipaddress - elif ipaddress.family == 6: - device.primary_ip6 = ipaddress - device.save() - - if '_addanother' in request.POST: - return redirect('dcim:ipaddress_assign', pk=device.pk) - else: - return redirect('dcim:device', pk=device.pk) - - else: - form = forms.IPAddressForm(device) - - return render(request, 'dcim/ipaddress_assign.html', { - 'device': device, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': device.pk}), - }) - - -# -# Modules -# - -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: @@ -1609,6 +1884,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 diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 2a06b3f2f..9d396dd3d 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django import forms from django.contrib import admin from django.utils.safestring import mark_safe diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py new file mode 100644 index 000000000..52f127a7d --- /dev/null +++ b/netbox/extras/api/customfields.py @@ -0,0 +1,164 @@ +from __future__ import unicode_literals +from datetime import datetime + +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from django.contrib.contenttypes.models import ContentType +from django.db import transaction + +from extras.models import ( + CF_TYPE_BOOLEAN, CF_TYPE_DATE, 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(): + + cf = custom_fields[field_name] + + # Validate custom field name + if field_name not in custom_fields: + raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name)) + + # Validate boolean + if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]: + raise ValidationError("Invalid value for boolean field {}: {}".format(field_name, value)) + + # Validate date + if cf.type == CF_TYPE_DATE: + try: + datetime.strptime(value, '%Y-%m-%d') + except ValueError: + raise ValidationError("Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format( + field_name, value + )) + + # Validate selected choice + if cf.type == CF_TYPE_SELECT: + try: + value = int(value) + except ValueError: + raise ValidationError("{}: Choice selections must be passed as integers.".format(field_name)) + valid_choices = [c.pk for c in cf.choices.all()] + if value not in valid_choices: + raise ValidationError("Invalid choice for field {}: {}".format(field_name, value)) + + # 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("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': custom_field.serialize_value(value)}, + ) + + def validate(self, data): + """ + Enforce model validation (see utilities.api.ModelValidationMixin) + """ + model_data = data.copy() + model_data.pop('custom_fields', None) + instance = self.Meta.model(**model_data) + instance.clean() + return data + + 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'] diff --git a/netbox/extras/api/renderers.py b/netbox/extras/api/renderers.py deleted file mode 100644 index 0fd35c762..000000000 --- a/netbox/extras/api/renderers.py +++ /dev/null @@ -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) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 4e82b4027..39ce63524 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,56 +1,140 @@ +from __future__ import unicode_literals + +from django.core.exceptions import ObjectDoesNotExist + from rest_framework import serializers -from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph +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, ModelValidationMixin -class CustomFieldSerializer(serializers.Serializer): - """ - Extends a ModelSerializer to render any CustomFields and their values associated with an object. - """ - custom_fields = serializers.SerializerMethodField() - - def get_custom_fields(self, obj): - - # Gather all CustomFields applicable to this object - fields = {cf.name: None for cf in self.context['view'].custom_fields} - - # Attach any defined CustomFieldValues to their respective CustomFields - for cfv in obj.custom_field_values.all(): - - # Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view - # context. - if cfv.field.type == CF_TYPE_SELECT and hasattr(self, 'custom_field_choices'): - cfc = { - 'id': int(cfv.serialized_value), - 'value': self.context['view'].custom_field_choices[int(cfv.serialized_value)] - } - fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data - # Fall back to hitting the database in case we're in a view that doesn't inherit CustomFieldModelAPIView. - elif cfv.field.type == CF_TYPE_SELECT: - fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data - else: - fields[cfv.field.name] = cfv.value - - return fields - - -class CustomFieldChoiceSerializer(serializers.ModelSerializer): - - class Meta: - model = CustomFieldChoice - fields = ['id', 'value'] - +# +# Graphs +# class GraphSerializer(serializers.ModelSerializer): - embed_url = serializers.SerializerMethodField() - embed_link = serializers.SerializerMethodField() + type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) class Meta: model = Graph - fields = ['name', 'embed_url', 'embed_link'] + fields = ['id', 'type', 'weight', 'name', 'source', 'link'] + + +class WritableGraphSerializer(serializers.ModelSerializer): + + class Meta: + model = Graph + fields = ['id', 'type', 'weight', 'name', 'source', 'link'] + + +class RenderedGraphSerializer(serializers.ModelSerializer): + embed_url = serializers.SerializerMethodField() + embed_link = serializers.SerializerMethodField() + type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES) + + class Meta: + model = Graph + fields = ['id', 'type', 'weight', 'name', 'embed_url', 'embed_link'] def get_embed_url(self, obj): return obj.embed_url(self.context['graphed_object']) def get_embed_link(self, obj): return obj.embed_link(self.context['graphed_object']) + + +# +# Export templates +# + +class ExportTemplateSerializer(serializers.ModelSerializer): + + class Meta: + model = ExportTemplate + fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension'] + + +# +# Topology maps +# + +class TopologyMapSerializer(serializers.ModelSerializer): + site = NestedSiteSerializer() + + class Meta: + model = TopologyMap + fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] + + +class WritableTopologyMapSerializer(serializers.ModelSerializer): + + class Meta: + model = TopologyMap + fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description'] + + +# +# 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(ModelValidationMixin, 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']) + ) + + # Enforce model validation + super(WritableImageAttachmentSerializer, self).validate(data) + + 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'] diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py new file mode 100644 index 000000000..c5268318c --- /dev/null +++ b/netbox/extras/api/urls.py @@ -0,0 +1,35 @@ +from __future__ import unicode_literals + +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 diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 19d7fab5f..37112f2c6 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,115 +1,97 @@ -import graphviz -from rest_framework import generics -from rest_framework.views import APIView +from __future__ import unicode_literals + +from rest_framework.decorators import detail_route +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from django.contrib.contenttypes.models import ContentType -from django.db.models import Q -from django.http import Http404, HttpResponse +from django.http import HttpResponse from django.shortcuts import get_object_or_404 -from circuits.models import Provider -from dcim.models import Site, Device, Interface, InterfaceConnection -from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE - -from .serializers import GraphSerializer +from extras import filters +from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction +from utilities.api import WritableSerializerMixin +from . import serializers -class CustomFieldModelAPIView(object): +class CustomFieldModelViewSet(ModelViewSet): """ - Include the applicable set of CustomField in the view context. + Include the applicable set of CustomFields in the ModelViewSet context. """ - def __init__(self): - super(CustomFieldModelAPIView, self).__init__() - self.content_type = ContentType.objects.get_for_model(self.queryset.model) - self.custom_fields = self.content_type.custom_fields.prefetch_related('choices') + def get_serializer_context(self): + + # Gather all custom fields for the model + content_type = ContentType.objects.get_for_model(self.queryset.model) + custom_fields = content_type.custom_fields.prefetch_related('choices') # Cache all relevant CustomFieldChoices. This saves us from having to do a lookup per select field per object. custom_field_choices = {} - for field in self.custom_fields: + for field in custom_fields: for cfc in field.choices.all(): custom_field_choices[cfc.id] = cfc.value - self.custom_field_choices = custom_field_choices + custom_field_choices = custom_field_choices - -class GraphListView(generics.ListAPIView): - """ - Returns a list of relevant graphs - """ - serializer_class = GraphSerializer - - def get_serializer_context(self): - cls = { - GRAPH_TYPE_INTERFACE: Interface, - GRAPH_TYPE_PROVIDER: Provider, - GRAPH_TYPE_SITE: Site, - } - context = super(GraphListView, self).get_serializer_context() - context.update({'graphed_object': get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk'])}) + context = super(CustomFieldModelViewSet, self).get_serializer_context() + context.update({ + 'custom_fields': custom_fields, + 'custom_field_choices': custom_field_choices, + }) return context def get_queryset(self): - graph_type = self.kwargs.get('type', None) - if not graph_type: - raise Http404() - queryset = Graph.objects.filter(type=graph_type) - return queryset + # Prefetch custom field values + return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field') -class TopologyMapView(APIView): - """ - Generate a topology diagram - """ +class GraphViewSet(WritableSerializerMixin, ModelViewSet): + queryset = Graph.objects.all() + serializer_class = serializers.GraphSerializer + write_serializer_class = serializers.WritableGraphSerializer + filter_class = filters.GraphFilter - def get(self, request, slug): - tmap = get_object_or_404(TopologyMap, slug=slug) +class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ExportTemplate.objects.all() + serializer_class = serializers.ExportTemplateSerializer + filter_class = filters.ExportTemplateFilter - # Construct the graph - graph = graphviz.Graph() - graph.graph_attr['ranksep'] = '1' - for i, device_set in enumerate(tmap.device_sets): - subgraph = graphviz.Graph(name='sg{}'.format(i)) - subgraph.graph_attr['rank'] = 'same' +class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet): + queryset = TopologyMap.objects.select_related('site') + serializer_class = serializers.TopologyMapSerializer + write_serializer_class = serializers.WritableTopologyMapSerializer + filter_class = filters.TopologyMapFilter - # Add a pseudonode for each device_set to enforce hierarchical layout - subgraph.node('set{}'.format(i), label='', shape='none', width='0') - if i: - graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis') + @detail_route() + def render(self, request, pk): - # Add each device to the graph - devices = [] - for query in device_set.split(';'): # Split regexes on semicolons - devices += Device.objects.filter(name__regex=query) - for d in devices: - subgraph.node(d.name) + tmap = get_object_or_404(TopologyMap, pk=pk) + img_format = 'png' - # Add an invisible connection to each successive device in a set to enforce horizontal order - for j in range(0, len(devices) - 1): - subgraph.edge(devices[j].name, devices[j + 1].name, style='invis') - - graph.subgraph(subgraph) - - # Compile list of all devices - device_superset = Q() - for device_set in tmap.device_sets: - for query in device_set.split(';'): # Split regexes on semicolons - device_superset = device_superset | Q(name__regex=query) - - # Add all connections to the graph - devices = Device.objects.filter(*(device_superset,)) - connections = InterfaceConnection.objects.filter(interface_a__device__in=devices, - interface_b__device__in=devices) - for c in connections: - graph.edge(c.interface_a.device.name, c.interface_b.device.name) - - # Get the image data and return try: - topo_data = graph.pipe(format='png') + data = tmap.render(img_format=img_format) except: - return HttpResponse("There was an error generating the requested graph. Ensure that the GraphViz " - "executables have been installed correctly.") - response = HttpResponse(topo_data, content_type='image/png') + return HttpResponse( + "There was an error generating the requested graph. Ensure that the GraphViz executables have been " + "installed correctly." + ) + + response = HttpResponse(data, content_type='image/{}'.format(img_format)) + response['Content-Disposition'] = 'inline; filename="{}.{}"'.format(tmap.slug, img_format) return response + + +class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet): + queryset = ImageAttachment.objects.all() + serializer_class = serializers.ImageAttachmentSerializer + write_serializer_class = serializers.WritableImageAttachmentSerializer + + +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 diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py new file mode 100644 index 000000000..86da90895 --- /dev/null +++ b/netbox/extras/constants.py @@ -0,0 +1,62 @@ +from __future__ import unicode_literals + + +# Models which support custom fields +CUSTOMFIELD_MODELS = ( + 'site', 'rack', 'devicetype', 'device', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM + 'provider', 'circuit', # Circuits + 'tenant', # Tenants +) + +# Custom field types +CF_TYPE_TEXT = 100 +CF_TYPE_INTEGER = 200 +CF_TYPE_BOOLEAN = 300 +CF_TYPE_DATE = 400 +CF_TYPE_URL = 500 +CF_TYPE_SELECT = 600 +CUSTOMFIELD_TYPE_CHOICES = ( + (CF_TYPE_TEXT, 'Text'), + (CF_TYPE_INTEGER, 'Integer'), + (CF_TYPE_BOOLEAN, 'Boolean (true/false)'), + (CF_TYPE_DATE, 'Date'), + (CF_TYPE_URL, 'URL'), + (CF_TYPE_SELECT, 'Selection'), +) + +# Graph types +GRAPH_TYPE_INTERFACE = 100 +GRAPH_TYPE_PROVIDER = 200 +GRAPH_TYPE_SITE = 300 +GRAPH_TYPE_CHOICES = ( + (GRAPH_TYPE_INTERFACE, 'Interface'), + (GRAPH_TYPE_PROVIDER, 'Provider'), + (GRAPH_TYPE_SITE, 'Site'), +) + +# Models which support export templates +EXPORTTEMPLATE_MODELS = [ + 'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM + 'provider', 'circuit', # Circuits + 'tenant', # Tenants +] + +# User action types +ACTION_CREATE = 1 +ACTION_IMPORT = 2 +ACTION_EDIT = 3 +ACTION_BULK_EDIT = 4 +ACTION_DELETE = 5 +ACTION_BULK_DELETE = 6 +ACTION_BULK_CREATE = 7 +ACTION_CHOICES = ( + (ACTION_CREATE, 'created'), + (ACTION_BULK_CREATE, 'bulk created'), + (ACTION_IMPORT, 'imported'), + (ACTION_EDIT, 'modified'), + (ACTION_BULK_EDIT, 'bulk edited'), + (ACTION_DELETE, 'deleted'), + (ACTION_BULK_DELETE, 'bulk deleted'), +) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index bcd9f175f..6bd5f3737 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,8 +1,12 @@ +from __future__ import unicode_literals + 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): @@ -28,7 +32,7 @@ class CustomFieldFilter(django_filters.Filter): pass return queryset.filter( custom_field_values__field__name=self.name, - custom_field_values__serialized_value=value, + custom_field_values__serialized_value__icontains=value, ) @@ -44,3 +48,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'] diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b4549fcf1..d7a06fa5f 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,11 +1,13 @@ +from __future__ import unicode_literals 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, ) @@ -103,7 +105,7 @@ class CustomFieldForm(forms.ModelForm): obj_id=self.instance.pk) except CustomFieldValue.DoesNotExist: # Skip this field if none exists already and its value is empty - if self.cleaned_data[field_name] in [None, u'']: + if self.cleaned_data[field_name] in [None, '']: continue cfv = CustomFieldValue( field=self.fields[field_name].model, @@ -158,3 +160,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'] diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py new file mode 100644 index 000000000..a50b1384d --- /dev/null +++ b/netbox/extras/management/commands/nbshell.py @@ -0,0 +1,72 @@ +from __future__ import unicode_literals + +import code +import platform +import sys + +from django import get_version +from django.apps import apps +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db.models import Model + + +APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users'] + +BANNER_TEXT = """### NetBox interactive shell ({node}) +### Python {python} | Django {django} | NetBox {netbox} +### lsmodels() will show available models. Use help() for more info.""".format( + node=platform.node(), + python=platform.python_version(), + django=get_version(), + netbox=settings.VERSION +) + + +class Command(BaseCommand): + help = "Start the Django shell with all NetBox models already imported" + django_models = {} + + def _lsmodels(self): + for app, models in self.django_models.items(): + app_name = apps.get_app_config(app).verbose_name + print('{}:'.format(app_name)) + for m in models: + print(' {}'.format(m)) + + def get_namespace(self): + namespace = {} + + # Gather Django models and constants from each app + for app in APPS: + self.django_models[app] = [] + + # Models + app_models = sys.modules['{}.models'.format(app)] + for name in dir(app_models): + model = getattr(app_models, name) + try: + if issubclass(model, Model) and model._meta.app_label == app: + namespace[name] = model + self.django_models[app].append(name) + except TypeError: + pass + + # Constants + try: + app_constants = sys.modules['{}.constants'.format(app)] + for name in dir(app_constants): + namespace[name] = getattr(app_constants, name) + except KeyError: + pass + + # Load convenience commands + namespace.update({ + 'lsmodels': self._lsmodels, + }) + + return namespace + + def handle(self, **options): + shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace()) + return shell diff --git a/netbox/extras/management/commands/run_inventory.py b/netbox/extras/management/commands/run_inventory.py index ebfee92e0..1e52b5c8f 100644 --- a/netbox/extras/management/commands/run_inventory.py +++ b/netbox/extras/management/commands/run_inventory.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from getpass import getpass from ncclient.transport.errors import AuthenticationError from paramiko import AuthenticationException @@ -6,7 +8,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 +27,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 +41,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 +74,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 +109,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 +121,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!") diff --git a/netbox/extras/migrations/0006_add_imageattachments.py b/netbox/extras/migrations/0006_add_imageattachments.py new file mode 100644 index 000000000..c4c589a9e --- /dev/null +++ b/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'], + }, + ), + ] diff --git a/netbox/extras/migrations/0007_unicode_literals.py b/netbox/extras/migrations/0007_unicode_literals.py new file mode 100644 index 000000000..c9a624510 --- /dev/null +++ b/netbox/extras/migrations/0007_unicode_literals.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-05-24 15:34 +from __future__ import unicode_literals + +from django.db import migrations, models +import extras.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0006_add_imageattachments'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='default', + field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100), + ), + migrations.AlterField( + model_name='customfield', + name='is_filterable', + field=models.BooleanField(default=True, help_text='This field can be used to filter objects.'), + ), + migrations.AlterField( + model_name='customfield', + name='label', + field=models.CharField(blank=True, help_text="Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50), + ), + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(help_text='The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'), + ), + migrations.AlterField( + model_name='customfield', + name='required', + field=models.BooleanField(default=False, help_text='Determines whether this field is required when creating new objects or editing an existing object.'), + ), + migrations.AlterField( + model_name='customfield', + name='type', + field=models.PositiveSmallIntegerField(choices=[(100, 'Text'), (200, 'Integer'), (300, 'Boolean (true/false)'), (400, 'Date'), (500, 'URL'), (600, 'Selection')], default=100), + ), + migrations.AlterField( + model_name='customfield', + name='weight', + field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form'), + ), + migrations.AlterField( + model_name='customfieldchoice', + name='weight', + field=models.PositiveSmallIntegerField(default=100, help_text='Higher weights appear lower in the list'), + ), + migrations.AlterField( + model_name='graph', + name='link', + field=models.URLField(blank=True, verbose_name='Link URL'), + ), + migrations.AlterField( + model_name='graph', + name='name', + field=models.CharField(max_length=100, verbose_name='Name'), + ), + migrations.AlterField( + model_name='graph', + name='source', + field=models.CharField(max_length=500, verbose_name='Source URL'), + ), + migrations.AlterField( + model_name='graph', + name='type', + field=models.PositiveSmallIntegerField(choices=[(100, 'Interface'), (200, 'Provider'), (300, 'Site')]), + ), + migrations.AlterField( + model_name='imageattachment', + name='image', + field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'), + ), + migrations.AlterField( + model_name='topologymap', + name='device_patterns', + field=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. Devices will be rendered in the order they are defined.'), + ), + migrations.AlterField( + model_name='useraction', + name='action', + field=models.PositiveSmallIntegerField(choices=[(1, 'created'), (7, 'bulk created'), (2, 'imported'), (3, 'modified'), (4, 'bulk edited'), (5, 'deleted'), (6, 'bulk deleted')]), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index b46c27f87..9d0e636ff 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,72 +1,26 @@ +from __future__ import unicode_literals 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 from django.utils.safestring import mark_safe +from utilities.utils import foreground_color +from .constants import * -CUSTOMFIELD_MODELS = ( - 'site', 'rack', 'devicetype', 'device', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM - 'provider', 'circuit', # Circuits - 'tenant', # Tenants -) - -CF_TYPE_TEXT = 100 -CF_TYPE_INTEGER = 200 -CF_TYPE_BOOLEAN = 300 -CF_TYPE_DATE = 400 -CF_TYPE_URL = 500 -CF_TYPE_SELECT = 600 -CUSTOMFIELD_TYPE_CHOICES = ( - (CF_TYPE_TEXT, 'Text'), - (CF_TYPE_INTEGER, 'Integer'), - (CF_TYPE_BOOLEAN, 'Boolean (true/false)'), - (CF_TYPE_DATE, 'Date'), - (CF_TYPE_URL, 'URL'), - (CF_TYPE_SELECT, 'Selection'), -) - -GRAPH_TYPE_INTERFACE = 100 -GRAPH_TYPE_PROVIDER = 200 -GRAPH_TYPE_SITE = 300 -GRAPH_TYPE_CHOICES = ( - (GRAPH_TYPE_INTERFACE, 'Interface'), - (GRAPH_TYPE_PROVIDER, 'Provider'), - (GRAPH_TYPE_SITE, 'Site'), -) - -EXPORTTEMPLATE_MODELS = [ - 'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM - 'provider', 'circuit', # Circuits - 'tenant', # Tenants -] - -ACTION_CREATE = 1 -ACTION_IMPORT = 2 -ACTION_EDIT = 3 -ACTION_BULK_EDIT = 4 -ACTION_DELETE = 5 -ACTION_BULK_DELETE = 6 -ACTION_BULK_CREATE = 7 -ACTION_CHOICES = ( - (ACTION_CREATE, 'created'), - (ACTION_BULK_CREATE, 'bulk created'), - (ACTION_IMPORT, 'imported'), - (ACTION_EDIT, 'modified'), - (ACTION_BULK_EDIT, 'bulk edited'), - (ACTION_DELETE, 'deleted'), - (ACTION_BULK_DELETE, 'bulk deleted'), -) +# +# Custom fields +# class CustomFieldModel(object): @@ -130,7 +84,11 @@ class CustomField(models.Model): if self.type == CF_TYPE_BOOLEAN: return str(int(bool(value))) if self.type == CF_TYPE_DATE: - return value.strftime('%Y-%m-%d') + # Could be date/datetime object or string + try: + return value.strftime('%Y-%m-%d') + except AttributeError: + return value if self.type == CF_TYPE_SELECT: # Could be ModelChoiceField or TypedChoiceField return str(value.id) if hasattr(value, 'id') else str(value) @@ -150,16 +108,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') @@ -170,7 +125,7 @@ class CustomFieldValue(models.Model): unique_together = ['field', 'obj_type', 'obj_id'] def __str__(self): - return u'{} {}'.format(self.obj, self.field) + return '{} {}'.format(self.obj, self.field) @property def value(self): @@ -213,6 +168,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 +197,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() @@ -254,7 +219,7 @@ class ExportTemplate(models.Model): ] def __str__(self): - return u'{}: {}'.format(self.content_type, self.name) + return '{}: {}'.format(self.content_type, self.name) def to_response(self, context_dict, filename): """ @@ -272,11 +237,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 +265,132 @@ 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 CONNECTION_STATUS_CONNECTED, 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: + bg_color = '#{}'.format(d.device_role.color) + fg_color = '#{}'.format(foreground_color(d.device_role.color)) + subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans') + + # 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: + style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' + graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style) + + # 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 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].lower() + if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']: + filename = '.'.join([instance.name, extension]) + elif instance.name: + filename = instance.name + + return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) + + +@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): @@ -359,8 +454,8 @@ class UserAction(models.Model): def __str__(self): if self.message: - return u'{} {}'.format(self.user, self.message) - return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type) + return '{} {}'.format(self.user, self.message) + return '{} {} {}'.format(self.user, self.get_action_display(), self.content_type) def icon(self): if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]: diff --git a/netbox/extras/rpc.py b/netbox/extras/rpc.py index b52dd0310..613bdb743 100644 --- a/netbox/extras/rpc.py +++ b/netbox/extras/rpc.py @@ -1,8 +1,10 @@ +from __future__ import unicode_literals +import re +import time + from ncclient import manager import paramiko -import re import xmltodict -import time CONNECT_TIMEOUT = 5 # seconds @@ -33,14 +35,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': , 'description': , } - 'modules': [ + 'items': [ { 'name': , 'part_id': , @@ -130,8 +132,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 +149,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 +178,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 +204,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 +212,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 +227,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 +262,7 @@ class OpengearSSH(SSHClient): 'serial': serial, 'description': description, }, - 'modules': [], + 'items': [], } diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py new file mode 100644 index 000000000..eddc6d71f --- /dev/null +++ b/netbox/extras/tests/test_api.py @@ -0,0 +1,170 @@ +from __future__ import unicode_literals + +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) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 791c6a1a2..5bbb407ce 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,17 +1,24 @@ +from __future__ import unicode_literals 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 - 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']) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py new file mode 100644 index 000000000..f980158e8 --- /dev/null +++ b/netbox/extras/urls.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals + +from django.conf.urls import url + +from extras import views + + +app_name = 'extras' +urlpatterns = [ + + # Image attachments + url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), + url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + +] diff --git a/netbox/extras/views.py b/netbox/extras/views.py new file mode 100644 index 000000000..06c852d68 --- /dev/null +++ b/netbox/extras/views.py @@ -0,0 +1,32 @@ +from __future__ import unicode_literals + +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 = 'extras.delete_imageattachment' + model = ImageAttachment + + def get_return_url(self, request, imageattachment): + return imageattachment.parent.get_absolute_url() diff --git a/netbox/generate_secret_key.py b/netbox/generate_secret_key.py index 0e0214dc4..3c88aa710 100755 --- a/netbox/generate_secret_key.py +++ b/netbox/generate_secret_key.py @@ -1,8 +1,7 @@ #!/usr/bin/env python # This script will generate a random 50-character string suitable for use as a SECRET_KEY. -import os import random charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)' -random.seed = (os.urandom(2048)) -print(''.join(random.choice(charset) for c in range(50))) +secure_random = random.SystemRandom() +print(''.join(secure_random.sample(charset, 50))) diff --git a/netbox/ipam/admin.py b/netbox/ipam/admin.py deleted file mode 100644 index f3f914129..000000000 --- a/netbox/ipam/admin.py +++ /dev/null @@ -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') diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e3f902605..1374d3552 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,88 +1,109 @@ -from rest_framework import serializers +from __future__ import unicode_literals +from collections import OrderedDict -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 rest_framework import serializers +from rest_framework.validators import UniqueTogetherValidator + +from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer +from extras.api.customfields import CustomFieldModelSerializer +from ipam.models import ( + Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, 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, ModelValidationMixin # # 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', 'display_name', 'custom_fields'] + + +class NestedVRFSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') + + class Meta: + model = VRF + fields = ['id', 'url', 'name', 'rd'] + + +class WritableVRFSerializer(CustomFieldModelSerializer): class Meta: model = VRF fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] -class VRFNestedSerializer(VRFSerializer): - - class Meta(VRFSerializer.Meta): - fields = ['id', 'name', 'rd'] - - -class VRFTenantSerializer(VRFSerializer): - """ - Include tenant serializer. Useful for determining tenant inheritance for Prefixes and IPAddresses. - """ - - class Meta(VRFSerializer.Meta): - fields = ['id', 'name', 'rd', 'tenant'] - - # # Roles # -class RoleSerializer(serializers.ModelSerializer): +class RoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Role 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'] # # RIRs # -class RIRSerializer(serializers.ModelSerializer): +class RIRSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RIR 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 +111,182 @@ class AggregateNestedSerializer(AggregateSerializer): # class VLANGroupSerializer(serializers.ModelSerializer): - site = SiteNestedSerializer() + site = NestedSiteSerializer() class Meta: model = VLANGroup fields = ['id', 'name', 'slug', 'site'] -class VLANGroupNestedSerializer(VLANGroupSerializer): +class NestedVLANGroupSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') - class Meta(VLANGroupSerializer.Meta): - fields = ['id', 'name', 'slug'] + class Meta: + model = VLANGroup + fields = ['id', 'url', 'name', 'slug'] + + +class WritableVLANGroupSerializer(serializers.ModelSerializer): + + class Meta: + model = VLANGroup + fields = ['id', 'name', 'slug', 'site'] + validators = [] + + def validate(self, data): + + # Validate uniqueness of name and slug if a site has been assigned. + if data.get('site', None): + for field in ['name', 'slug']: + validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field)) + validator.set_context(self) + validator(data) + + # Enforce model validation + super(WritableVLANGroupSerializer, self).validate(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) + + # Enforce model validation + super(WritableVLANSerializer, self).validate(data) + + return data # # Prefixes # -class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer): - site = SiteNestedSerializer() - vrf = VRFTenantSerializer() - tenant = TenantNestedSerializer() - vlan = VLANNestedSerializer() - role = RoleNestedSerializer() +class PrefixSerializer(CustomFieldModelSerializer): + site = NestedSiteSerializer() + vrf = NestedVRFSerializer() + tenant = NestedTenantSerializer() + vlan = NestedVLANSerializer() + status = ChoiceFieldSerializer(choices=PREFIX_STATUS_CHOICES) + role = NestedRoleSerializer() class Meta: model = Prefix - fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', - 'custom_fields'] + fields = [ + 'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', + 'custom_fields', + ] -class PrefixNestedSerializer(PrefixSerializer): +class NestedPrefixSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') - class Meta(PrefixSerializer.Meta): - fields = ['id', 'family', 'prefix'] + class Meta: + model = Prefix + fields = ['id', 'url', 'family', 'prefix'] + + +class WritablePrefixSerializer(CustomFieldModelSerializer): + + 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) + role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_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', 'role', 'interface', 'description', 'nat_inside', + 'nat_outside', 'custom_fields', + ] -class IPAddressNestedSerializer(IPAddressSerializer): +class NestedIPAddressSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') - class Meta(IPAddressSerializer.Meta): - fields = ['id', 'family', 'address'] + class Meta: + model = IPAddress + fields = ['id', 'url', 'family', 'address'] -IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer() -IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer() +IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer() +IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer() + + +class WritableIPAddressSerializer(CustomFieldModelSerializer): + + class Meta: + model = IPAddress + fields = [ + 'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', + 'custom_fields', + ] + + +class AvailableIPSerializer(serializers.Serializer): + + def to_representation(self, instance): + if self.context.get('vrf'): + vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data + else: + vrf = None + return OrderedDict([ + ('family', self.context['prefix'].version), + ('address', '{}/{}'.format(instance, self.context['prefix'].prefixlen)), + ('vrf', vrf), + ]) # @@ -177,15 +294,18 @@ 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): +# TODO: Figure out how to use ModelValidationMixin with ManyToManyFields. Calling clean() yields a ValueError. +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'] diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 598545ddf..e6b1bb13d 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,44 +1,43 @@ -from django.conf.urls import url +from __future__ import unicode_literals -from .views import * +from rest_framework import routers + +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\d+)/$', VRFDetailView.as_view(), name='vrf_detail'), - # Roles - url(r'^roles/$', RoleListView.as_view(), name='role_list'), - url(r'^roles/(?P\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\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\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\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\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\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\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\d+)/$', ServiceDetailView.as_view(), name='service_detail'), +# Services +router.register(r'services', views.ServiceViewSet) -] +app_name = 'ipam-api' +urlpatterns = router.urls diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 10b9c46e4..87511d5c5 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,9 +1,18 @@ -from rest_framework import generics +from __future__ import unicode_literals + +from rest_framework import status +from rest_framework.decorators import detail_route +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from django.conf import settings +from django.shortcuts import get_object_or_404 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,190 +20,150 @@ 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 - """ - queryset = Role.objects.all() - serializer_class = serializers.RoleSerializer - - # # 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 +# +# Roles +# + +class RoleViewSet(ModelViewSet): + queryset = Role.objects.all() + serializer_class = serializers.RoleSerializer + filter_class = filters.RoleFilter # # 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 + @detail_route(url_path='available-ips', methods=['get', 'post']) + def available_ips(self, request, pk=None): + """ + A convenience method for returning available IP addresses within a prefix. By default, the number of IPs + returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed, + however results will not be paginated. + """ + prefix = get_object_or_404(Prefix, pk=pk) -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 + # Create the next available IP within the prefix + if request.method == 'POST': + + # Permissions check + if not request.user.has_perm('ipam.add_ipaddress'): + raise PermissionDenied() + + # Find the first available IP address in the prefix + try: + ipaddress = list(prefix.get_available_ips())[0] + except IndexError: + return Response( + { + "detail": "There are no available IPs within this prefix ({})".format(prefix) + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Create the new IP address + data = request.data.copy() + data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen) + data['vrf'] = prefix.vrf + serializer = serializers.WritableIPAddressSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Determine the maximum amount of IPs to return + else: + try: + limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT)) + except ValueError: + limit = settings.PAGINATE_COUNT + if settings.MAX_PAGE_SIZE: + limit = min(limit, settings.MAX_PAGE_SIZE) + + # Calculate available IPs within the prefix + ip_list = list(prefix.get_available_ips())[:limit] + serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ + 'request': request, + 'prefix': prefix.prefix, + 'vrf': prefix.vrf, + }) + + return Response(serializer.data) # # 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 diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index fd4af74b0..c944d1b2c 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django.apps import AppConfig diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py new file mode 100644 index 000000000..3beb18823 --- /dev/null +++ b/netbox/ipam/constants.py @@ -0,0 +1,78 @@ +from __future__ import unicode_literals + + +# IP address families +AF_CHOICES = ( + (4, 'IPv4'), + (6, 'IPv6'), +) + +# Prefix statuses +PREFIX_STATUS_CONTAINER = 0 +PREFIX_STATUS_ACTIVE = 1 +PREFIX_STATUS_RESERVED = 2 +PREFIX_STATUS_DEPRECATED = 3 +PREFIX_STATUS_CHOICES = ( + (PREFIX_STATUS_CONTAINER, 'Container'), + (PREFIX_STATUS_ACTIVE, 'Active'), + (PREFIX_STATUS_RESERVED, 'Reserved'), + (PREFIX_STATUS_DEPRECATED, 'Deprecated') +) + +# IP address statuses +IPADDRESS_STATUS_ACTIVE = 1 +IPADDRESS_STATUS_RESERVED = 2 +IPADDRESS_STATUS_DEPRECATED = 3 +IPADDRESS_STATUS_DHCP = 5 +IPADDRESS_STATUS_CHOICES = ( + (IPADDRESS_STATUS_ACTIVE, 'Active'), + (IPADDRESS_STATUS_RESERVED, 'Reserved'), + (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'), + (IPADDRESS_STATUS_DHCP, 'DHCP') +) + +# IP address roles +IPADDRESS_ROLE_LOOPBACK = 10 +IPADDRESS_ROLE_SECONDARY = 20 +IPADDRESS_ROLE_ANYCAST = 30 +IPADDRESS_ROLE_VIP = 40 +IPADDRESS_ROLE_VRRP = 41 +IPADDRESS_ROLE_HSRP = 42 +IPADDRESS_ROLE_GLBP = 43 +IPADDRESS_ROLE_CHOICES = ( + (IPADDRESS_ROLE_LOOPBACK, 'Loopback'), + (IPADDRESS_ROLE_SECONDARY, 'Secondary'), + (IPADDRESS_ROLE_ANYCAST, 'Anycast'), + (IPADDRESS_ROLE_VIP, 'VIP'), + (IPADDRESS_ROLE_VRRP, 'VRRP'), + (IPADDRESS_ROLE_HSRP, 'HSRP'), + (IPADDRESS_ROLE_GLBP, 'GLBP'), +) + +# VLAN statuses +VLAN_STATUS_ACTIVE = 1 +VLAN_STATUS_RESERVED = 2 +VLAN_STATUS_DEPRECATED = 3 +VLAN_STATUS_CHOICES = ( + (VLAN_STATUS_ACTIVE, 'Active'), + (VLAN_STATUS_RESERVED, 'Reserved'), + (VLAN_STATUS_DEPRECATED, 'Deprecated') +) + +# Bootstrap CSS classes for various statuses +STATUS_CHOICE_CLASSES = { + 0: 'default', + 1: 'primary', + 2: 'info', + 3: 'danger', + 4: 'warning', + 5: 'success', +} + +# IP protocols (for services) +IP_PROTOCOL_TCP = 6 +IP_PROTOCOL_UDP = 17 +IP_PROTOCOL_CHOICES = ( + (IP_PROTOCOL_TCP, 'TCP'), + (IP_PROTOCOL_UDP, 'UDP'), +) diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index c44385b6d..a20a5dce2 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from netaddr import IPNetwork from django.core.exceptions import ValidationError diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 3c39a4308..045ca1df4 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import django_filters from netaddr import IPNetwork from netaddr.core import AddrFormatError @@ -8,8 +10,10 @@ from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter - -from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from .models import ( + Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, + Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, +) class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -19,7 +23,6 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -41,7 +44,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = VRF - fields = ['name', 'rd'] + fields = ['name', 'rd', 'enforce_unique'] class RIRFilter(django_filters.FilterSet): @@ -49,7 +52,7 @@ class RIRFilter(django_filters.FilterSet): class Meta: model = RIR - fields = ['is_private'] + fields = ['name', 'slug', 'is_private'] class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -59,7 +62,6 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) rir_id = django_filters.ModelMultipleChoiceFilter( - name='rir', queryset=RIR.objects.all(), label='RIR (ID)', ) @@ -81,11 +83,18 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): try: prefix = str(IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) - except AddrFormatError: + except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) +class RoleFilter(django_filters.FilterSet): + + class Meta: + model = Role + fields = ['name', 'slug'] + + class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -101,7 +110,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Mask length', ) vrf_id = NullableModelMultipleChoiceFilter( - name='vrf_id', queryset=VRF.objects.all(), label='VRF', ) @@ -112,7 +120,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VRF (RD)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -123,7 +130,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) site_id = NullableModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -134,7 +140,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (slug)', ) vlan_id = NullableModelMultipleChoiceFilter( - name='vlan', queryset=VLAN.objects.all(), label='VLAN (ID)', ) @@ -143,7 +148,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VLAN number (1-4095)', ) role_id = NullableModelMultipleChoiceFilter( - name='role', queryset=Role.objects.all(), label='Role (ID)', ) @@ -153,10 +157,13 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=PREFIX_STATUS_CHOICES + ) class Meta: model = Prefix - fields = ['family', 'status'] + fields = ['family', 'is_pool'] def search(self, queryset, name, value): if not value.strip(): @@ -165,7 +172,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): try: prefix = str(IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) - except AddrFormatError: + except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -176,7 +183,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): try: query = str(IPNetwork(value).cidr) return queryset.filter(prefix__net_contained_or_equal=query) - except AddrFormatError: + except (AddrFormatError, ValueError): return queryset.none() def filter_mask_length(self, queryset, name, value): @@ -200,7 +207,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Mask length', ) vrf_id = NullableModelMultipleChoiceFilter( - name='vrf_id', queryset=VRF.objects.all(), label='VRF', ) @@ -211,7 +217,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VRF (RD)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -233,14 +238,19 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device (name)', ) interface_id = django_filters.ModelMultipleChoiceFilter( - name='interface', queryset=Interface.objects.all(), label='Interface (ID)', ) + status = django_filters.MultipleChoiceFilter( + choices=IPADDRESS_STATUS_CHOICES + ) + role = django_filters.MultipleChoiceFilter( + choices=IPADDRESS_ROLE_CHOICES + ) class Meta: model = IPAddress - fields = ['family', 'status'] + fields = ['family'] def search(self, queryset, name, value): if not value.strip(): @@ -249,7 +259,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): try: ipaddress = str(IPNetwork(value.strip())) qs_filter |= Q(address__net_host=ipaddress) - except AddrFormatError: + except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -260,7 +270,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): try: query = str(IPNetwork(value.strip()).cidr) return queryset.filter(address__net_host_contained=query) - except AddrFormatError: + except (AddrFormatError, ValueError): return queryset.none() def filter_mask_length(self, queryset, name, value): @@ -271,7 +281,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class VLANGroupFilter(django_filters.FilterSet): site_id = NullableModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -284,7 +293,7 @@ class VLANGroupFilter(django_filters.FilterSet): class Meta: model = VLANGroup - fields = ['name'] + fields = ['name', 'slug'] class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -294,7 +303,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) site_id = NullableModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -305,7 +313,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (slug)', ) group_id = NullableModelMultipleChoiceFilter( - name='group', queryset=VLANGroup.objects.all(), label='Group (ID)', ) @@ -316,7 +323,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -327,7 +333,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) role_id = NullableModelMultipleChoiceFilter( - name='role', queryset=Role.objects.all(), label='Role (ID)', ) @@ -337,10 +342,13 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=VLAN_STATUS_CHOICES + ) class Meta: model = VLAN - fields = ['name', 'vid', 'status'] + fields = ['vid', 'name'] def search(self, queryset, name, value): if not value.strip(): @@ -355,7 +363,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class ServiceFilter(django_filters.FilterSet): device_id = django_filters.ModelMultipleChoiceFilter( - name='device', queryset=Device.objects.all(), label='Device (ID)', ) diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py index 914310be9..8d30e11e5 100644 --- a/netbox/ipam/formfields.py +++ b/netbox/ipam/formfields.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from netaddr import IPNetwork, AddrFormatError from django import forms diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 72b73208c..e19376e8e 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,17 +1,21 @@ +from __future__ import unicode_literals + from django import forms +from django.core.exceptions import MultipleObjectsReturned from django.db.models import Count from dcim.models import Site, Rack, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from tenancy.forms import TenancyForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch, - SlugField, add_blank_choice, + APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField, + ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField, + add_blank_choice, ) - from .models import ( - Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, - VLANGroup, VLAN_STATUS_CHOICES, VRF, + Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, + Service, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, ) @@ -32,11 +36,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)] # VRFs # -class VRFForm(BootstrapMixin, CustomFieldForm): +class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = VRF - fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant'] labels = { 'rd': "RD", } @@ -45,22 +49,31 @@ class VRFForm(BootstrapMixin, CustomFieldForm): } -class VRFFromCSVForm(forms.ModelForm): - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) +class VRFCSVForm(forms.ModelForm): + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) class Meta: model = VRF fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] - - -class VRFImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=VRFFromCSVForm) + help_texts = { + 'name': 'VRF name', + } class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + enforce_unique = forms.NullBooleanField( + required=False, widget=BulkEditNullBooleanSelect, label='Enforce unique space' + ) description = forms.CharField(max_length=100, required=False) class Meta: @@ -110,19 +123,21 @@ class AggregateForm(BootstrapMixin, CustomFieldForm): } -class AggregateFromCSVForm(forms.ModelForm): - rir = forms.ModelChoiceField(queryset=RIR.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'RIR not found.'}) +class AggregateCSVForm(forms.ModelForm): + rir = forms.ModelChoiceField( + queryset=RIR.objects.all(), + to_field_name='name', + help_text='Name of parent RIR', + error_messages={ + 'invalid_choice': 'RIR not found.', + } + ) class Meta: model = Aggregate fields = ['prefix', 'rir', 'date_added', 'description'] -class AggregateImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=AggregateFromCSVForm) - - class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') @@ -160,89 +175,147 @@ class RoleForm(BootstrapMixin, forms.ModelForm): # Prefixes # -class PrefixForm(BootstrapMixin, CustomFieldForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', - widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'})) - vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', - widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}', - display_field='display_name')) +class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + widget=forms.Select( + attrs={'filter-for': 'vlan_group', 'nullable': 'true'} + ) + ) + vlan_group = ChainedModelChoiceField( + queryset=VLANGroup.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + label='VLAN group', + widget=APISelect( + api_url='/api/ipam/vlan-groups/?site_id={{site}}', + attrs={'filter-for': 'vlan', 'nullable': 'true'} + ) + ) + vlan = ChainedModelChoiceField( + queryset=VLAN.objects.all(), + chains=( + ('site', 'site'), + ('group', 'vlan_group'), + ), + required=False, + label='VLAN', + widget=APISelect( + api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', display_field='display_name' + ) + ) class Meta: model = Prefix - fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description'] + fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant'] def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance and instance.vlan is not None: + initial['vlan_group'] = instance.vlan.group + kwargs['initial'] = initial + super(PrefixForm, self).__init__(*args, **kwargs) self.fields['vrf'].empty_label = 'Global' - # Initialize field without choices to avoid pulling all VLANs from the database - if self.is_bound and self.data.get('site'): - self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site']) - else: - self.fields['vlan'].queryset = VLAN.objects.filter(site=None) - -class PrefixFromCSVForm(forms.ModelForm): - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', - error_messages={'invalid_choice': 'VRF not found.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'}) - vlan_group_name = forms.CharField(required=False) - vlan_vid = forms.IntegerField(required=False) - status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES]) - role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Invalid role.'}) +class PrefixCSVForm(forms.ModelForm): + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Route distinguisher of parent VRF', + error_messages={ + 'invalid_choice': 'VRF not found.', + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + vlan_group = forms.CharField( + help_text='Group name of assigned VLAN', + required=False + ) + vlan_vid = forms.IntegerField( + help_text='Numeric ID of assigned VLAN', + required=False + ) + status = CSVChoiceField( + choices=PREFIX_STATUS_CHOICES, + help_text='Operational status' + ) + role = forms.ModelChoiceField( + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text='Functional role', + error_messages={ + 'invalid_choice': 'Invalid role.', + } + ) class Meta: model = Prefix - fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool', - 'description'] + fields = [ + 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', + ] def clean(self): - super(PrefixFromCSVForm, self).clean() + super(PrefixCSVForm, self).clean() site = self.cleaned_data.get('site') - vlan_group_name = self.cleaned_data.get('vlan_group_name') + vlan_group = self.cleaned_data.get('vlan_group') vlan_vid = self.cleaned_data.get('vlan_vid') # Validate VLAN - vlan_group = None - if vlan_group_name: + if vlan_group and vlan_vid: try: - vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name) - except VLANGroup.DoesNotExist: - self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name)) - if vlan_vid and vlan_group: - try: - self.instance.vlan = VLAN.objects.get(group=vlan_group, vid=vlan_vid) + self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid) except VLAN.DoesNotExist: - self.add_error('vlan_vid', "Invalid VLAN ID ({} - {}).".format(vlan_group, vlan_vid)) - elif vlan_vid and site: - try: - self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid) - except VLAN.DoesNotExist: - self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site)) - except VLAN.MultipleObjectsReturned: - self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid)) + if site: + raise forms.ValidationError("VLAN {} not found in site {} group {}".format( + vlan_vid, site, vlan_group + )) + else: + raise forms.ValidationError("Global VLAN {} not found in group {}".format(vlan_vid, vlan_group)) + except MultipleObjectsReturned: + raise forms.ValidationError( + "Multiple VLANs with VID {} found in group {}".format(vlan_vid, vlan_group) + ) elif vlan_vid: - self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.") - - def save(self, *args, **kwargs): - - # Assign Prefix status by name - self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] - - return super(PrefixFromCSVForm, self).save(*args, **kwargs) - - -class PrefixImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=PrefixFromCSVForm) + try: + self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, vid=vlan_vid) + except VLAN.DoesNotExist: + if site: + raise forms.ValidationError("VLAN {} not found in site {}".format(vlan_vid, site)) + else: + raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid)) + except MultipleObjectsReturned: + raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid)) class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -252,6 +325,7 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) + is_pool = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a pool') description = forms.CharField(max_length=100, required=False) class Meta: @@ -262,7 +336,7 @@ def prefix_status_choices(): status_counts = {} for status in Prefix.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 PREFIX_STATUS_CHOICES] + return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES] class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): @@ -302,149 +376,252 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): # IP addresses # -class IPAddressForm(BootstrapMixin, CustomFieldForm): - nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', - widget=forms.Select(attrs={'filter-for': 'nat_device'})) - nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', - widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}', - display_field='display_name', - attrs={'filter-for': 'nat_inside'})) - livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch( - query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address') - ) - - class Meta: - model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description'] - widgets = { - 'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address') - } - - def __init__(self, *args, **kwargs): - super(IPAddressForm, self).__init__(*args, **kwargs) - - self.fields['vrf'].empty_label = 'Global' - - if self.instance.nat_inside: - - nat_inside = self.instance.nat_inside - # If the IP is assigned to an interface, populate site/device fields accordingly - if self.instance.nat_inside.interface: - self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk - self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk - self.fields['nat_device'].queryset = Device.objects.filter( - site=nat_inside.interface.device.site - ) - self.fields['nat_inside'].queryset = IPAddress.objects.filter( - interface__device=nat_inside.interface.device - ) - else: - self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk) - - else: - - # Initialize nat_device choices if nat_site is set - if self.is_bound and self.data.get('nat_site'): - self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site']) - elif self.initial.get('nat_site'): - self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site']) - else: - self.fields['nat_device'].choices = [] - - # Initialize nat_inside choices if nat_device is set - if self.is_bound and self.data.get('nat_device'): - self.fields['nat_inside'].queryset = IPAddress.objects.filter( - interface__device__pk=self.data['nat_device']) - elif self.initial.get('nat_device'): - self.fields['nat_inside'].queryset = IPAddress.objects.filter( - interface__device__pk=self.initial['nat_device']) - else: - self.fields['nat_inside'].choices = [] - - -class IPAddressBulkAddForm(BootstrapMixin, forms.Form): - address = ExpandableIPAddressField() - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global') - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES) - description = forms.CharField(max_length=100, required=False) - - -class IPAddressAssignForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField( +class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm): + interface_site = forms.ModelChoiceField( queryset=Site.objects.all(), + required=False, label='Site', - required=False, widget=forms.Select( - attrs={'filter-for': 'rack'} + attrs={'filter-for': 'interface_rack'} ) ) - rack = forms.ModelChoiceField( + interface_rack = ChainedModelChoiceField( queryset=Rack.objects.all(), - label='Rack', + chains=( + ('site', 'interface_site'), + ), required=False, + label='Rack', widget=APISelect( - api_url='/api/dcim/racks/?site_id={{site}}', + api_url='/api/dcim/racks/?site_id={{interface_site}}', display_field='display_name', - attrs={'filter-for': 'device', 'nullable': 'true'} + attrs={'filter-for': 'interface_device', 'nullable': 'true'} ) ) - device = forms.ModelChoiceField( + interface_device = ChainedModelChoiceField( queryset=Device.objects.all(), - label='Device', + chains=( + ('site', 'interface_site'), + ('rack', 'interface_rack'), + ), required=False, + label='Device', widget=APISelect( - api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}', display_field='display_name', attrs={'filter-for': 'interface'} ) ) - livesearch = forms.CharField( + interface = ChainedModelChoiceField( + queryset=Interface.objects.all(), + chains=( + ('device', 'interface_device'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/interfaces/?device_id={{interface_device}}' + ) + ) + nat_site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + widget=forms.Select( + attrs={'filter-for': 'nat_rack'} + ) + ) + nat_rack = ChainedModelChoiceField( + queryset=Rack.objects.all(), + chains=( + ('site', 'nat_site'), + ), + required=False, + label='Rack', + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{nat_site}}', + display_field='display_name', + attrs={'filter-for': 'nat_device', 'nullable': 'true'} + ) + ) + nat_device = ChainedModelChoiceField( + queryset=Device.objects.all(), + chains=( + ('site', 'nat_site'), + ('rack', 'nat_rack'), + ), required=False, label='Device', + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{nat_site}}&rack_id={{nat_rack}}', + display_field='display_name', + attrs={'filter-for': 'nat_inside'} + ) + ) + nat_inside = ChainedModelChoiceField( + queryset=IPAddress.objects.all(), + chains=( + ('interface__device', 'nat_device'), + ), + required=False, + label='IP Address', + widget=APISelect( + api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', + display_field='address' + ) + ) + livesearch = forms.CharField( + required=False, + label='Search', widget=Livesearch( query_key='q', - query_url='dcim-api:device_list', - field_to_update='device' + query_url='ipam-api:ipaddress-list', + field_to_update='nat_inside', + obj_label='address' ) ) - interface = forms.ModelChoiceField( - queryset=Interface.objects.all(), - label='Interface', - widget=APISelect( - api_url='/api/dcim/devices/{{device}}/interfaces/' - ) - ) - set_as_primary = forms.BooleanField( - label='Set as primary IP for device', - required=False - ) - - def __init__(self, *args, **kwargs): - - super(IPAddressAssignForm, self).__init__(*args, **kwargs) - - self.fields['rack'].choices = [] - self.fields['device'].choices = [] - self.fields['interface'].choices = [] - - -class IPAddressFromCSVForm(forms.ModelForm): - vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', - error_messages={'invalid_choice': 'VRF not found.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES]) - device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Device not found.'}) - interface_name = forms.CharField(required=False) - is_primary = forms.BooleanField(required=False) + primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device') class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description'] + fields = [ + 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack', + 'nat_inside', 'tenant_group', 'tenant', + ] + + def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance and instance.interface is not None: + initial['interface_site'] = instance.interface.device.site + initial['interface_rack'] = instance.interface.device.rack + initial['interface_device'] = instance.interface.device + if instance and instance.nat_inside and instance.nat_inside.device is not None: + initial['nat_site'] = instance.nat_inside.device.site + initial['nat_rack'] = instance.nat_inside.device.rack + initial['nat_device'] = instance.nat_inside.device + kwargs['initial'] = initial + + super(IPAddressForm, self).__init__(*args, **kwargs) + + self.fields['vrf'].empty_label = 'Global' + + # Initialize primary_for_device if IP address is already assigned + if self.instance.interface is not None: + device = self.instance.interface.device + if ( + self.instance.address.version == 4 and device.primary_ip4 == self.instance or + self.instance.address.version == 6 and device.primary_ip6 == self.instance + ): + self.initial['primary_for_device'] = True def clean(self): + super(IPAddressForm, self).clean() + + # Primary IP assignment is only available if an interface has been assigned. + if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'): + self.add_error( + 'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs." + ) + + def save(self, *args, **kwargs): + + ipaddress = super(IPAddressForm, self).save(*args, **kwargs) + + # Assign this IPAddress as the primary for the associated Device. + if self.cleaned_data['primary_for_device']: + device = self.cleaned_data['interface'].device + if ipaddress.address.version == 4: + device.primary_ip4 = ipaddress + else: + device.primary_ip6 = ipaddress + device.save() + + # Clear assignment as primary for device if set. + else: + try: + if ipaddress.address.version == 4: + device = ipaddress.primary_ip4_for + device.primary_ip4 = None + else: + device = ipaddress.primary_ip6_for + device.primary_ip6 = None + device.save() + except Device.DoesNotExist: + pass + + return ipaddress + + +class IPAddressPatternForm(BootstrapMixin, forms.Form): + pattern = ExpandableIPAddressField(label='Address pattern') + + +class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): + + class Meta: + model = IPAddress + fields = ['address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant'] + + def __init__(self, *args, **kwargs): + super(IPAddressBulkAddForm, self).__init__(*args, **kwargs) + self.fields['vrf'].empty_label = 'Global' + + +class IPAddressCSVForm(forms.ModelForm): + vrf = forms.ModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Route distinguisher of the assigned VRF', + error_messages={ + 'invalid_choice': 'VRF not found.', + } + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + required=False, + help_text='Name of the assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + status = CSVChoiceField( + choices=IPADDRESS_STATUS_CHOICES, + help_text='Operational status' + ) + role = CSVChoiceField( + choices=IPADDRESS_ROLE_CHOICES, + required=False, + help_text='Functional role' + ) + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of assigned device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + interface_name = forms.CharField( + help_text='Name of assigned interface', + required=False + ) + is_primary = forms.BooleanField( + help_text='Make this the primary IP for the assigned device', + required=False + ) + + class Meta: + model = IPAddress + fields = ['address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description'] + + def clean(self): + + super(IPAddressCSVForm, self).clean() device = self.cleaned_data.get('device') interface_name = self.cleaned_data.get('interface_name') @@ -453,39 +630,39 @@ class IPAddressFromCSVForm(forms.ModelForm): # Validate interface if device and interface_name: try: - Interface.objects.get(device=device, name=interface_name) + self.instance.interface = Interface.objects.get(device=device, name=interface_name) except Interface.DoesNotExist: - self.add_error('interface_name', "Invalid interface ({}) for {}".format(interface_name, device)) + raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device)) elif device and not interface_name: - self.add_error('interface_name', "Device set ({}) but interface missing".format(device)) + raise forms.ValidationError("Device set ({}) but interface missing".format(device)) elif interface_name and not device: - self.add_error('device', "Interface set ({}) but device missing or invalid".format(interface_name)) + raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name)) # Validate is_primary if is_primary and not device: - self.add_error('is_primary', "No device specified; cannot set as primary IP") + raise forms.ValidationError("No device specified; cannot set as primary IP") def save(self, *args, **kwargs): - # Assign status by name - self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] - # Set interface if self.cleaned_data['device'] and self.cleaned_data['interface_name']: - self.instance.interface = Interface.objects.get(device=self.cleaned_data['device'], - name=self.cleaned_data['interface_name']) + self.instance.interface = Interface.objects.get( + device=self.cleaned_data['device'], + name=self.cleaned_data['interface_name'] + ) + + ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs) + # Set as primary for device if self.cleaned_data['is_primary']: + device = self.cleaned_data['device'] if self.instance.address.version == 4: - self.instance.primary_ip4_for = self.cleaned_data['device'] + device.primary_ip4 = ipaddress elif self.instance.address.version == 6: - self.instance.primary_ip6_for = self.cleaned_data['device'] + device.primary_ip6 = ipaddress + device.save() - return super(IPAddressFromCSVForm, self).save(*args, **kwargs) - - -class IPAddressImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=IPAddressFromCSVForm) + return ipaddress class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -493,17 +670,25 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False) + role = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_ROLE_CHOICES), required=False) description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['vrf', 'tenant', 'description'] + nullable_fields = ['vrf', 'role', 'tenant', 'description'] def ipaddress_status_choices(): status_counts = {} for status in IPAddress.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 IPADDRESS_STATUS_CHOICES] + return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES] + + +def ipaddress_role_choices(): + role_counts = {} + for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'): + role_counts[role['role']] = role['count'] + return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES] class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): @@ -526,6 +711,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=(0, 'None') ) status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False) + role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False) # @@ -552,14 +738,29 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # VLANs # -class VLANForm(BootstrapMixin, CustomFieldForm): - group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect( - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - )) +class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=forms.Select( + attrs={'filter-for': 'group', 'nullable': 'true'} + ) + ) + group = ChainedModelChoiceField( + queryset=VLANGroup.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + label='Group', + widget=APISelect( + api_url='/api/ipam/vlan-groups/?site_id={{site}}', + ) + ) class Meta: model = VLAN - fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant'] help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", @@ -568,49 +769,69 @@ class VLANForm(BootstrapMixin, CustomFieldForm): 'status': "Operational status of this VLAN", 'role': "The primary function of this VLAN", } - widgets = { - 'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}), + + +class VLANCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', } - - def __init__(self, *args, **kwargs): - - super(VLANForm, self).__init__(*args, **kwargs) - - # Limit VLAN group choices - if self.is_bound and self.data.get('site'): - self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site']) - elif self.initial.get('site'): - self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site']) - else: - self.fields['group'].queryset = VLANGroup.objects.filter(site=None) - - -class VLANFromCSVForm(forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'}) - group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'VLAN group not found.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES]) - role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Invalid role.'}) + ) + group_name = forms.CharField( + help_text='Name of VLAN group', + required=False + ) + tenant = forms.ModelChoiceField( + queryset=Tenant.objects.all(), + to_field_name='name', + required=False, + help_text='Name of assigned tenant', + error_messages={ + 'invalid_choice': 'Tenant not found.', + } + ) + status = CSVChoiceField( + choices=VLAN_STATUS_CHOICES, + help_text='Operational status' + ) + role = forms.ModelChoiceField( + queryset=Role.objects.all(), + required=False, + to_field_name='name', + help_text='Functional role', + error_messages={ + 'invalid_choice': 'Invalid role.', + } + ) class Meta: model = VLAN - fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description'] + fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + help_texts = { + 'vid': 'Numeric VLAN ID (1-4095)', + 'name': 'VLAN name', + } - def save(self, *args, **kwargs): - m = super(VLANFromCSVForm, self).save(commit=False) - # Assign VLAN status by name - m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] - if kwargs.get('commit'): - m.save() - return m + def clean(self): + super(VLANCSVForm, self).clean() -class VLANImportForm(BootstrapMixin, BulkImportForm): - csv = CSVDataField(csv_form=VLANFromCSVForm) + site = self.cleaned_data.get('site') + group_name = self.cleaned_data.get('group_name') + + # Validate VLAN group + if group_name: + try: + self.instance.group = VLANGroup.objects.get(site=site, name=group_name) + except VLANGroup.DoesNotExist: + if site: + raise forms.ValidationError("VLAN group {} not found for site {}".format(group_name, site)) + else: + raise forms.ValidationError("Global VLAN group {} not found".format(group_name)) class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): @@ -623,14 +844,14 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['group', 'tenant', 'role', 'description'] + nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] def vlan_status_choices(): status_counts = {} for status in VLAN.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 VLAN_STATUS_CHOICES] + return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES] class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index 05c69dfb2..ef5cf8327 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from django.db.models import Lookup, Transform, IntegerField from django.db.models.lookups import BuiltinLookup diff --git a/netbox/ipam/migrations/0016_unicode_literals.py b/netbox/ipam/migrations/0016_unicode_literals.py new file mode 100644 index 000000000..bb29542ad --- /dev/null +++ b/netbox/ipam/migrations/0016_unicode_literals.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-05-24 15:34 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0015_global_vlans'), + ] + + operations = [ + migrations.AlterField( + model_name='aggregate', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')]), + ), + migrations.AlterField( + model_name='aggregate', + name='rir', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name='RIR'), + ), + migrations.AlterField( + model_name='ipaddress', + name='address', + field=ipam.fields.IPAddressField(help_text='IPv4 or IPv6 address (with mask)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.OneToOneField(blank=True, help_text='The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name='NAT (Inside)'), + ), + migrations.AlterField( + model_name='ipaddress', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, verbose_name='Status'), + ), + migrations.AlterField( + model_name='ipaddress', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name='VRF'), + ), + migrations.AlterField( + model_name='prefix', + name='family', + field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False), + ), + migrations.AlterField( + model_name='prefix', + name='is_pool', + field=models.BooleanField(default=False, help_text='All IP addresses within this prefix are considered usable', verbose_name='Is a pool'), + ), + migrations.AlterField( + model_name='prefix', + name='prefix', + field=ipam.fields.IPNetworkField(help_text='IPv4 or IPv6 network with mask'), + ), + migrations.AlterField( + model_name='prefix', + name='role', + field=models.ForeignKey(blank=True, help_text='The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'), + ), + migrations.AlterField( + model_name='prefix', + name='status', + field=models.PositiveSmallIntegerField(choices=[(0, 'Container'), (1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, help_text='Operational status of this prefix', verbose_name='Status'), + ), + migrations.AlterField( + model_name='prefix', + name='vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name='VLAN'), + ), + migrations.AlterField( + model_name='prefix', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name='VRF'), + ), + migrations.AlterField( + model_name='rir', + name='is_private', + field=models.BooleanField(default=False, help_text='IP space managed by this RIR is considered private', verbose_name='Private'), + ), + migrations.AlterField( + model_name='service', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'), + ), + migrations.AlterField( + model_name='service', + name='ipaddresses', + field=models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name='IP addresses'), + ), + migrations.AlterField( + model_name='service', + name='port', + field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name='Port number'), + ), + migrations.AlterField( + model_name='service', + name='protocol', + field=models.PositiveSmallIntegerField(choices=[(6, 'TCP'), (17, 'UDP')]), + ), + migrations.AlterField( + model_name='vlan', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, verbose_name='Status'), + ), + migrations.AlterField( + model_name='vlan', + name='vid', + field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name='ID'), + ), + migrations.AlterField( + model_name='vrf', + name='enforce_unique', + field=models.BooleanField(default=True, help_text='Prevent duplicate prefixes/IP addresses within this VRF', verbose_name='Enforce unique space'), + ), + migrations.AlterField( + model_name='vrf', + name='rd', + field=models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher'), + ), + ] diff --git a/netbox/ipam/migrations/0017_ipaddress_roles.py b/netbox/ipam/migrations/0017_ipaddress_roles.py new file mode 100644 index 000000000..d91c3daa9 --- /dev/null +++ b/netbox/ipam/migrations/0017_ipaddress_roles.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-06-16 19:37 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0016_unicode_literals'), + ] + + operations = [ + migrations.AddField( + model_name='ipaddress', + name='role', + field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP')], help_text='The functional role of this IP', null=True, verbose_name='Role'), + ), + migrations.AlterField( + model_name='ipaddress', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, help_text='The operational status of this IP', verbose_name='Status'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 51807d0fa..04853c5da 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,12 +1,13 @@ -from netaddr import IPNetwork, cidr_merge +from __future__ import unicode_literals +import netaddr 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 @@ -15,64 +16,10 @@ from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel from utilities.sql import NullsFirstQuerySet from utilities.utils import csv_format - +from .constants import * from .fields import IPNetworkField, IPAddressField -AF_CHOICES = ( - (4, 'IPv4'), - (6, 'IPv6'), -) - -PREFIX_STATUS_CONTAINER = 0 -PREFIX_STATUS_ACTIVE = 1 -PREFIX_STATUS_RESERVED = 2 -PREFIX_STATUS_DEPRECATED = 3 -PREFIX_STATUS_CHOICES = ( - (PREFIX_STATUS_CONTAINER, 'Container'), - (PREFIX_STATUS_ACTIVE, 'Active'), - (PREFIX_STATUS_RESERVED, 'Reserved'), - (PREFIX_STATUS_DEPRECATED, 'Deprecated') -) - -IPADDRESS_STATUS_ACTIVE = 1 -IPADDRESS_STATUS_RESERVED = 2 -IPADDRESS_STATUS_DEPRECATED = 3 -IPADDRESS_STATUS_DHCP = 5 -IPADDRESS_STATUS_CHOICES = ( - (IPADDRESS_STATUS_ACTIVE, 'Active'), - (IPADDRESS_STATUS_RESERVED, 'Reserved'), - (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'), - (IPADDRESS_STATUS_DHCP, 'DHCP') -) - -VLAN_STATUS_ACTIVE = 1 -VLAN_STATUS_RESERVED = 2 -VLAN_STATUS_DEPRECATED = 3 -VLAN_STATUS_CHOICES = ( - (VLAN_STATUS_ACTIVE, 'Active'), - (VLAN_STATUS_RESERVED, 'Reserved'), - (VLAN_STATUS_DEPRECATED, 'Deprecated') -) - -STATUS_CHOICE_CLASSES = { - 0: 'default', - 1: 'primary', - 2: 'info', - 3: 'danger', - 4: 'warning', - 5: 'success', -} - - -IP_PROTOCOL_TCP = 6 -IP_PROTOCOL_UDP = 17 -IP_PROTOCOL_CHOICES = ( - (IP_PROTOCOL_TCP, 'TCP'), - (IP_PROTOCOL_UDP, 'UDP'), -) - - @python_2_unicode_compatible class VRF(CreatedUpdatedModel, CustomFieldModel): """ @@ -88,13 +35,15 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): description = models.CharField(max_length=100, blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + class Meta: ordering = ['name'] verbose_name = 'VRF' verbose_name_plural = 'VRFs' def __str__(self): - return self.name + return self.display_name or super(VRF, self).__str__() def get_absolute_url(self): return reverse('ipam:vrf', args=[self.pk]) @@ -108,6 +57,12 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): self.description, ]) + @property + def display_name(self): + if self.name and self.rd: + return "{} ({})".format(self.name, self.rd) + return None + @python_2_unicode_compatible class RIR(models.Model): @@ -145,6 +100,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): description = models.CharField(max_length=100, blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + csv_headers = ['prefix', 'rir', 'date_added', 'description'] + class Meta: ordering = ['family', 'prefix'] @@ -199,15 +156,11 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): def get_utilization(self): """ - Determine the utilization rate of the aggregate prefix and return it as a percentage. + Determine the prefix utilization of the aggregate and return it as a percentage. """ - child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix)) - # Remove overlapping prefixes from list of children - networks = cidr_merge([c.prefix for c in child_prefixes]) - children_size = float(0) - for p in networks: - children_size += p.size - return int(children_size / self.prefix.size * 100) + queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix)) + child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) + return int(float(child_prefixes.size) / self.prefix.size * 100) @python_2_unicode_compatible @@ -296,6 +249,10 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): objects = PrefixQuerySet.as_manager() + csv_headers = [ + 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', + ] + class Meta: ordering = ['vrf', 'family', 'prefix'] verbose_name_plural = 'prefixes' @@ -306,9 +263,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): def get_absolute_url(self): return reverse('ipam:prefix', args=[self.pk]) - def get_duplicates(self): - return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk) - def clean(self): if self.prefix: @@ -356,20 +310,64 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): self.description, ]) + def get_status_class(self): + return STATUS_CHOICE_CLASSES[self.status] + + def get_duplicates(self): + return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk) + + def get_child_ips(self): + """ + Return all IPAddresses within this Prefix. + """ + return IPAddress.objects.filter(address__net_contained_or_equal=str(self.prefix), vrf=self.vrf) + + def get_available_ips(self): + """ + Return all available IPs within this prefix as an IPSet. + """ + prefix = netaddr.IPSet(self.prefix) + child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) + available_ips = prefix - child_ips + + # Remove unusable IPs from non-pool prefixes + if not self.is_pool: + available_ips -= netaddr.IPSet([ + netaddr.IPAddress(self.prefix.first), + netaddr.IPAddress(self.prefix.last), + ]) + + return available_ips + + def get_utilization(self): + """ + Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of + "container", calculate utilization based on child prefixes. For all others, count child IP addresses. + """ + if self.status == PREFIX_STATUS_CONTAINER: + queryset = Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf) + child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) + return int(float(child_prefixes.size) / self.prefix.size * 100) + else: + child_count = IPAddress.objects.filter( + address__net_contained_or_equal=str(self.prefix), vrf=self.vrf + ).count() + prefix_size = self.prefix.size + if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool: + prefix_size -= 2 + return int(float(child_count) / prefix_size * 100) + @property def new_subnet(self): if self.family == 4: if self.prefix.prefixlen <= 30: - return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) + return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) return None if self.family == 6: if self.prefix.prefixlen <= 126: - return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) + return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) return None - def get_status_class(self): - return STATUS_CHOICE_CLASSES[self.status] - class IPAddressManager(models.Manager): @@ -402,7 +400,13 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) - status = models.PositiveSmallIntegerField('Status', choices=IPADDRESS_STATUS_CHOICES, default=1) + status = models.PositiveSmallIntegerField( + 'Status', choices=IPADDRESS_STATUS_CHOICES, default=IPADDRESS_STATUS_ACTIVE, + help_text='The operational status of this IP' + ) + role = models.PositiveSmallIntegerField( + 'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP' + ) interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, null=True) nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, @@ -413,6 +417,10 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): objects = IPAddressManager() + csv_headers = [ + 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description', + ] + class Meta: ordering = ['family', 'address'] verbose_name = 'IP address' @@ -451,17 +459,19 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): def to_csv(self): # Determine if this IP is primary for a Device - is_primary = False if self.family == 4 and getattr(self, 'primary_ip4_for', False): is_primary = True elif self.family == 6 and getattr(self, 'primary_ip6_for', False): is_primary = True + else: + is_primary = False return csv_format([ self.address, self.vrf.rd if self.vrf else None, self.tenant.name if self.tenant else None, self.get_status_display(), + self.get_role_display(), self.device.identifier if self.device else None, self.interface.name if self.interface else None, is_primary, @@ -497,9 +507,7 @@ class VLANGroup(models.Model): verbose_name_plural = 'VLAN groups' def __str__(self): - if self.site is None: - return self.name - return u'{} - {}'.format(self.site.name, self.name) + return self.name def get_absolute_url(self): return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) @@ -528,6 +536,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): description = models.CharField(max_length=100, blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') + csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + class Meta: ordering = ['site', 'group', 'vid'] unique_together = [ @@ -538,7 +548,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): verbose_name_plural = 'VLANs' def __str__(self): - return self.display_name + return self.display_name or super(VLAN, self).__str__() def get_absolute_url(self): return reverse('ipam:vlan', args=[self.pk]) @@ -565,7 +575,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): @property def display_name(self): - return u'{} ({})'.format(self.vid, self.name) + if self.vid and self.name: + return "{} ({})".format(self.vid, self.name) + return None def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] @@ -591,4 +603,4 @@ class Service(CreatedUpdatedModel): unique_together = ['device', 'protocol', 'port'] def __str__(self): - return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 6c99f7d9e..65ab5b2e4 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -1,8 +1,9 @@ +from __future__ import unicode_literals + import django_tables2 as tables from django_tables2.utils import Accessor from utilities.tables import BaseTable, ToggleColumn - from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF @@ -33,7 +34,7 @@ RIR_ACTIONS = """ UTILIZATION_GRAPH = """ {% load helpers %} -{% utilization_graph value %} +{% if record.pk %}{% utilization_graph value %}{% else %}—{% endif %} """ ROLE_ACTIONS = """ @@ -70,9 +71,18 @@ IPADDRESS_LINK = """ {% if record.pk %} {{ record.address }} {% elif perms.ipam.add_ipaddress %} - {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }} + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% else %} - {{ record.0 }} + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available +{% endif %} +""" + +IPADDRESS_DEVICE = """ +{% if record.interface %} + {{ record.interface.device }} + ({{ record.interface.name }}) +{% else %} + — {% endif %} """ @@ -133,10 +143,9 @@ 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 @@ -152,6 +161,14 @@ class RIRTable(BaseTable): name = tables.LinkColumn(verbose_name='Name') is_private = tables.BooleanColumn(verbose_name='Private') aggregate_count = tables.Column(verbose_name='Aggregates') + actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') + + class Meta(BaseTable.Meta): + model = RIR + fields = ('pk', 'name', 'is_private', 'aggregate_count', 'actions') + + +class RIRDetailTable(RIRTable): stats_total = tables.Column(accessor='stats.total', verbose_name='Total', footer=lambda table: sum(r.stats['total'] for r in table.data)) stats_active = tables.Column(accessor='stats.active', verbose_name='Active', @@ -163,12 +180,12 @@ class RIRTable(BaseTable): stats_available = tables.Column(accessor='stats.available', verbose_name='Available', footer=lambda table: sum(r.stats['available'] for r in table.data)) utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization') - actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') - class Meta(BaseTable.Meta): - model = RIR - fields = ('pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved', - 'stats_deprecated', 'stats_available', 'utilization', 'actions') + class Meta(RIRTable.Meta): + fields = ( + 'pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved', + 'stats_deprecated', 'stats_available', 'utilization', 'actions', + ) # @@ -177,15 +194,19 @@ 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') - child_count = tables.Column(verbose_name='Prefixes') - get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') + prefix = tables.LinkColumn(verbose_name='Aggregate') 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', 'date_added', 'description') + + +class AggregateDetailTable(AggregateTable): + child_count = tables.Column(verbose_name='Prefixes') + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') + + class Meta(AggregateTable.Meta): fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description') @@ -212,14 +233,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 @@ -229,18 +249,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') +class PrefixDetailTable(PrefixTable): + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') - class Meta(BaseTable.Meta): - model = Prefix - fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role') - orderable = False + class Meta(PrefixTable.Meta): + fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description') # @@ -250,33 +263,29 @@ 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.TemplateColumn(IPADDRESS_DEVICE, orderable=False) + interface = tables.Column(orderable=False) class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description') + fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } -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)') +class IPAddressDetailTable(IPAddressTable): + 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 Meta(IPAddressTable.Meta): + fields = ( + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description', + ) # @@ -304,15 +313,19 @@ 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', 'tenant', 'status', 'role', 'description') + + +class VLANDetailTable(VLANTable): + prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') + + class Meta(VLANTable.Meta): fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py new file mode 100644 index 000000000..1a40b95a5 --- /dev/null +++ b/netbox/ipam/tests/test_api.py @@ -0,0 +1,690 @@ +from __future__ import unicode_literals + +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) + + def test_available_ips(self): + + prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True) + url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) + + # Retrieve all available IPs + response = self.client.get(url, **self.header) + self.assertEqual(len(response.data), 8) # 8 because prefix.is_pool = True + + # Change the prefix to not be a pool and try again + prefix.is_pool = False + prefix.save() + response = self.client.get(url, **self.header) + self.assertEqual(len(response.data), 6) # 8 - 2 because prefix.is_pool = False + + # Create all six available IPs + for i in range(6): + data = { + 'description': 'Test IP {}'.format(i) + } + response = self.client.post(url, data, **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['description'], data['description']) + + # Try to create one more IP + response = self.client.post(url, {}, **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + + +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) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 3385c643f..0f75cc795 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -1,9 +1,11 @@ +from __future__ import unicode_literals + import netaddr +from django.core.exceptions import ValidationError from django.test import TestCase, override_settings from ipam.models import IPAddress, Prefix, VRF -from django.core.exceptions import ValidationError class TestPrefix(TestCase): diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 5ef052b37..15634c0ee 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -1,79 +1,80 @@ +from __future__ import unicode_literals + from django.conf.urls import url from . import views +app_name = 'ipam' urlpatterns = [ # VRFs url(r'^vrfs/$', views.VRFListView.as_view(), name='vrf_list'), - url(r'^vrfs/add/$', views.VRFEditView.as_view(), name='vrf_add'), + url(r'^vrfs/add/$', views.VRFCreateView.as_view(), name='vrf_add'), url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'), url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), - url(r'^vrfs/(?P\d+)/$', views.vrf, name='vrf'), + url(r'^vrfs/(?P\d+)/$', views.VRFView.as_view(), name='vrf'), url(r'^vrfs/(?P\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'), url(r'^vrfs/(?P\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'), # RIRs url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), - url(r'^rirs/add/$', views.RIREditView.as_view(), name='rir_add'), + url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'), url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), url(r'^rirs/(?P[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), # Aggregates url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'), - url(r'^aggregates/add/$', views.AggregateEditView.as_view(), name='aggregate_add'), + url(r'^aggregates/add/$', views.AggregateCreateView.as_view(), name='aggregate_add'), url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'), url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), - url(r'^aggregates/(?P\d+)/$', views.aggregate, name='aggregate'), + url(r'^aggregates/(?P\d+)/$', views.AggregateView.as_view(), name='aggregate'), url(r'^aggregates/(?P\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'), url(r'^aggregates/(?P\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'), # Roles url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), - url(r'^roles/add/$', views.RoleEditView.as_view(), name='role_add'), + url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'), url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), url(r'^roles/(?P[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), # Prefixes url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'), - url(r'^prefixes/add/$', views.PrefixEditView.as_view(), name='prefix_add'), + url(r'^prefixes/add/$', views.PrefixCreateView.as_view(), name='prefix_add'), url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'), url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), - url(r'^prefixes/(?P\d+)/$', views.prefix, name='prefix'), + url(r'^prefixes/(?P\d+)/$', views.PrefixView.as_view(), name='prefix'), url(r'^prefixes/(?P\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'), url(r'^prefixes/(?P\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'), - url(r'^prefixes/(?P\d+)/ip-addresses/$', views.prefix_ipaddresses, name='prefix_ipaddresses'), + url(r'^prefixes/(?P\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), # IP addresses url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'), - url(r'^ip-addresses/add/$', views.IPAddressEditView.as_view(), name='ipaddress_add'), - url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkAddView.as_view(), name='ipaddress_bulk_add'), + url(r'^ip-addresses/add/$', views.IPAddressCreateView.as_view(), name='ipaddress_add'), + url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), - url(r'^ip-addresses/(?P\d+)/$', views.ipaddress, name='ipaddress'), + url(r'^ip-addresses/(?P\d+)/$', views.IPAddressView.as_view(), name='ipaddress'), url(r'^ip-addresses/(?P\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), - url(r'^ip-addresses/(?P\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'), - url(r'^ip-addresses/(?P\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'), url(r'^ip-addresses/(?P\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), # VLAN groups url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'), - url(r'^vlan-groups/add/$', views.VLANGroupEditView.as_view(), name='vlangroup_add'), + url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), url(r'^vlan-groups/(?P\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), # VLANs url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), - url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'), + url(r'^vlans/add/$', views.VLANCreateView.as_view(), name='vlan_add'), url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'), url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), - url(r'^vlans/(?P\d+)/$', views.vlan, name='vlan'), + url(r'^vlans/(?P\d+)/$', views.VLANView.as_view(), name='vlan'), url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 864909878..05f16aa35 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,21 +1,20 @@ +from __future__ import unicode_literals + from django_tables2 import RequestConfig import netaddr from django.conf import settings -from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin -from django.contrib import messages -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.shortcuts import get_object_or_404, render +from django.urls import reverse +from django.views.generic import View from dcim.models import Device -from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) - from . import filters, forms, tables from .models import ( Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, @@ -99,28 +98,34 @@ class VRFListView(ObjectListView): template_name = 'ipam/vrf_list.html' -def vrf(request, pk): +class VRFView(View): - vrf = get_object_or_404(VRF.objects.all(), pk=pk) - prefix_table = tables.PrefixBriefTable( - list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role')) - ) - prefix_table.exclude = ('vrf',) + def get(self, request, pk): - return render(request, 'ipam/vrf.html', { - 'vrf': vrf, - 'prefix_table': prefix_table, - }) + vrf = get_object_or_404(VRF.objects.all(), pk=pk) + prefix_table = tables.PrefixTable( + list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role')), orderable=False + ) + prefix_table.exclude = ('vrf',) + + return render(request, 'ipam/vrf.html', { + 'vrf': vrf, + 'prefix_table': prefix_table, + }) -class VRFEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_vrf' +class VRFCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_vrf' model = VRF form_class = forms.VRFForm template_name = 'ipam/vrf_edit.html' default_return_url = 'ipam:vrf_list' +class VRFEditView(VRFCreateView): + permission_required = 'ipam.change_vrf' + + class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_vrf' model = VRF @@ -129,25 +134,27 @@ class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_vrf' - form = forms.VRFImportForm + model_form = forms.VRFCSVForm table = tables.VRFTable - template_name = 'ipam/vrf_import.html' default_return_url = 'ipam:vrf_list' class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vrf' cls = VRF + queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter + table = tables.VRFTable form = forms.VRFBulkEditForm - template_name = 'ipam/vrf_bulk_edit.html' default_return_url = 'ipam:vrf_list' class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vrf' cls = VRF + queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter + table = tables.VRFTable default_return_url = 'ipam:vrf_list' @@ -159,7 +166,7 @@ class RIRListView(ObjectListView): queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filter = filters.RIRFilter filter_form = forms.RIRFilterForm - table = tables.RIRTable + table = tables.RIRDetailTable template_name = 'ipam/rir_list.html' def alter_queryset(self, request): @@ -239,19 +246,25 @@ class RIRListView(ObjectListView): } -class RIREditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_rir' +class RIRCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_rir' model = RIR form_class = forms.RIRForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('ipam:rir_list') +class RIREditView(RIRCreateView): + permission_required = 'ipam.change_rir' + + class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_rir' cls = RIR + queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filter = filters.RIRFilter + table = tables.RIRTable default_return_url = 'ipam:rir_list' @@ -265,7 +278,7 @@ class AggregateListView(ObjectListView): }) filter = filters.AggregateFilter filter_form = forms.AggregateFilterForm - table = tables.AggregateTable + table = tables.AggregateDetailTable template_name = 'ipam/aggregate_list.html' def extra_context(self): @@ -284,47 +297,58 @@ class AggregateListView(ObjectListView): } -def aggregate(request, pk): +class AggregateView(View): - aggregate = get_object_or_404(Aggregate, pk=pk) + def get(self, request, pk): - # Find all child prefixes contained by this aggregate - child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))\ - .select_related('site', 'role').annotate_depth(limit=0) - child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes) + aggregate = get_object_or_404(Aggregate, pk=pk) - prefix_table = tables.PrefixTable(child_prefixes) - if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): - prefix_table.base_columns['pk'].visible = True + # Find all child prefixes contained by this aggregate + child_prefixes = Prefix.objects.filter( + prefix__net_contained_or_equal=str(aggregate.prefix) + ).select_related( + 'site', 'role' + ).annotate_depth( + limit=0 + ) + child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes) - paginate = { - 'klass': EnhancedPaginator, - 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) - } - RequestConfig(request, paginate).configure(prefix_table) + prefix_table = tables.PrefixTable(child_prefixes) + if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): + prefix_table.base_columns['pk'].visible = True - # Compile permissions list for rendering the object table - permissions = { - 'add': request.user.has_perm('ipam.add_prefix'), - 'change': request.user.has_perm('ipam.change_prefix'), - 'delete': request.user.has_perm('ipam.delete_prefix'), - } + paginate = { + 'klass': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(prefix_table) - return render(request, 'ipam/aggregate.html', { - 'aggregate': aggregate, - 'prefix_table': prefix_table, - 'permissions': permissions, - }) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_prefix'), + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + + return render(request, 'ipam/aggregate.html', { + 'aggregate': aggregate, + 'prefix_table': prefix_table, + 'permissions': permissions, + }) -class AggregateEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_aggregate' +class AggregateCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_aggregate' model = Aggregate form_class = forms.AggregateForm template_name = 'ipam/aggregate_edit.html' default_return_url = 'ipam:aggregate_list' +class AggregateEditView(AggregateCreateView): + permission_required = 'ipam.change_aggregate' + + class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_aggregate' model = Aggregate @@ -333,25 +357,27 @@ class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_aggregate' - form = forms.AggregateImportForm + model_form = forms.AggregateCSVForm table = tables.AggregateTable - template_name = 'ipam/aggregate_import.html' default_return_url = 'ipam:aggregate_list' class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_aggregate' cls = Aggregate + queryset = Aggregate.objects.select_related('rir') filter = filters.AggregateFilter + table = tables.AggregateTable form = forms.AggregateBulkEditForm - template_name = 'ipam/aggregate_bulk_edit.html' default_return_url = 'ipam:aggregate_list' class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_aggregate' cls = Aggregate + queryset = Aggregate.objects.select_related('rir') filter = filters.AggregateFilter + table = tables.AggregateTable default_return_url = 'ipam:aggregate_list' @@ -365,18 +391,23 @@ class RoleListView(ObjectListView): template_name = 'ipam/role_list.html' -class RoleEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_role' +class RoleCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_role' model = Role form_class = forms.RoleForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('ipam:role_list') +class RoleEditView(RoleCreateView): + permission_required = 'ipam.change_role' + + class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_role' cls = Role + table = tables.RoleTable default_return_url = 'ipam:role_list' @@ -388,7 +419,7 @@ class PrefixListView(ObjectListView): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter filter_form = forms.PrefixFilterForm - table = tables.PrefixTable + table = tables.PrefixDetailTable template_name = 'ipam/prefix_list.html' def alter_queryset(self, request): @@ -397,77 +428,134 @@ class PrefixListView(ObjectListView): return self.queryset.annotate_depth(limit=limit) -def prefix(request, pk): +class PrefixView(View): - prefix = get_object_or_404(Prefix.objects.select_related( - 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' - ), pk=pk) + def get(self, request, pk): - try: - aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) - except Aggregate.DoesNotExist: - aggregate = None + prefix = get_object_or_404(Prefix.objects.select_related( + 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' + ), pk=pk) - # Count child IP addresses - ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\ - .count() + try: + aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) + except Aggregate.DoesNotExist: + aggregate = None - # Parent prefixes table - parent_prefixes = Prefix.objects.filter(Q(vrf=prefix.vrf) | Q(vrf__isnull=True))\ - .filter(prefix__net_contains=str(prefix.prefix))\ - .select_related('site', 'role').annotate_depth() - parent_prefix_table = tables.PrefixBriefTable(parent_prefixes) - parent_prefix_table.exclude = ('vrf',) + # Count child IP addresses + ipaddress_count = IPAddress.objects.filter( + vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix) + ).count() - # Duplicate prefixes table - duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\ - .select_related('site', 'role') - duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes)) - duplicate_prefix_table.exclude = ('vrf',) + # Parent prefixes table + parent_prefixes = Prefix.objects.filter( + Q(vrf=prefix.vrf) | Q(vrf__isnull=True) + ).filter( + prefix__net_contains=str(prefix.prefix) + ).select_related( + 'site', 'role' + ).annotate_depth() + parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False) + parent_prefix_table.exclude = ('vrf',) - # Child prefixes table - child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\ - .select_related('site', 'role').annotate_depth(limit=0) - if child_prefixes: - child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) - child_prefix_table = tables.PrefixTable(child_prefixes) - if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): - child_prefix_table.base_columns['pk'].visible = True + # Duplicate prefixes table + duplicate_prefixes = Prefix.objects.filter( + vrf=prefix.vrf, prefix=str(prefix.prefix) + ).exclude( + pk=prefix.pk + ).select_related( + 'site', 'role' + ) + duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False) + duplicate_prefix_table.exclude = ('vrf',) - paginate = { - 'klass': EnhancedPaginator, - 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) - } - RequestConfig(request, paginate).configure(child_prefix_table) + # Child prefixes table + child_prefixes = Prefix.objects.filter( + vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix) + ).select_related( + 'site', 'role' + ).annotate_depth(limit=0) + if child_prefixes: + child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) + child_prefix_table = tables.PrefixTable(child_prefixes) + if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): + child_prefix_table.base_columns['pk'].visible = True - # Compile permissions list for rendering the object table - permissions = { - 'add': request.user.has_perm('ipam.add_prefix'), - 'change': request.user.has_perm('ipam.change_prefix'), - 'delete': request.user.has_perm('ipam.delete_prefix'), - } + paginate = { + 'klass': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(child_prefix_table) - return render(request, 'ipam/prefix.html', { - 'prefix': prefix, - 'aggregate': aggregate, - 'ipaddress_count': ipaddress_count, - 'parent_prefix_table': parent_prefix_table, - 'child_prefix_table': child_prefix_table, - 'duplicate_prefix_table': duplicate_prefix_table, - 'permissions': permissions, - 'return_url': prefix.get_absolute_url(), - }) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_prefix'), + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + + return render(request, 'ipam/prefix.html', { + 'prefix': prefix, + 'aggregate': aggregate, + 'ipaddress_count': ipaddress_count, + 'parent_prefix_table': parent_prefix_table, + 'child_prefix_table': child_prefix_table, + 'duplicate_prefix_table': duplicate_prefix_table, + 'permissions': permissions, + 'return_url': prefix.get_absolute_url(), + }) -class PrefixEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_prefix' +class PrefixIPAddressesView(View): + + def get(self, request, pk): + + prefix = get_object_or_404(Prefix.objects.all(), pk=pk) + + # Find all IPAddresses belonging to this Prefix + ipaddresses = IPAddress.objects.filter( + vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix) + ).select_related( + 'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for' + ) + ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool) + + ip_table = tables.IPAddressTable(ipaddresses) + if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): + ip_table.base_columns['pk'].visible = True + + paginate = { + 'klass': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(ip_table) + + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_ipaddress'), + 'change': request.user.has_perm('ipam.change_ipaddress'), + 'delete': request.user.has_perm('ipam.delete_ipaddress'), + } + + return render(request, 'ipam/prefix_ipaddresses.html', { + 'prefix': prefix, + 'ip_table': ip_table, + 'permissions': permissions, + 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix), + }) + + +class PrefixCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_prefix' model = Prefix form_class = forms.PrefixForm template_name = 'ipam/prefix_edit.html' - fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan'] default_return_url = 'ipam:prefix_list' +class PrefixEditView(PrefixCreateView): + permission_required = 'ipam.change_prefix' + + class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_prefix' model = Prefix @@ -477,227 +565,135 @@ class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView): class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_prefix' - form = forms.PrefixImportForm + model_form = forms.PrefixCSVForm table = tables.PrefixTable - template_name = 'ipam/prefix_import.html' default_return_url = 'ipam:prefix_list' class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_prefix' cls = Prefix + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter + table = tables.PrefixTable form = forms.PrefixBulkEditForm - template_name = 'ipam/prefix_bulk_edit.html' default_return_url = 'ipam:prefix_list' class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_prefix' cls = Prefix + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter + table = tables.PrefixTable default_return_url = 'ipam:prefix_list' -def prefix_ipaddresses(request, pk): - - prefix = get_object_or_404(Prefix.objects.all(), pk=pk) - - # Find all IPAddresses belonging to this Prefix - ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\ - .select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for') - ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool) - - ip_table = tables.IPAddressTable(ipaddresses) - if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): - ip_table.base_columns['pk'].visible = True - - paginate = { - 'klass': EnhancedPaginator, - 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) - } - RequestConfig(request, paginate).configure(ip_table) - - # Compile permissions list for rendering the object table - permissions = { - 'add': request.user.has_perm('ipam.add_ipaddress'), - 'change': request.user.has_perm('ipam.change_ipaddress'), - 'delete': request.user.has_perm('ipam.delete_ipaddress'), - } - - return render(request, 'ipam/prefix_ipaddresses.html', { - 'prefix': prefix, - 'ip_table': ip_table, - 'permissions': permissions, - }) - - # # IP addresses # class IPAddressListView(ObjectListView): - queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') filter = filters.IPAddressFilter filter_form = forms.IPAddressFilterForm - table = tables.IPAddressTable + table = tables.IPAddressDetailTable template_name = 'ipam/ipaddress_list.html' -def ipaddress(request, pk): +class IPAddressView(View): - ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk) + def get(self, request, pk): - # Parent prefixes table - parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\ - .select_related('site', 'role') - parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes)) - parent_prefixes_table.exclude = ('vrf',) + ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk) - # Duplicate IPs table - duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\ - .exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside') - duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips)) + # Parent prefixes table + parent_prefixes = Prefix.objects.filter( + vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip) + ).select_related( + 'site', 'role' + ) + parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False) + parent_prefixes_table.exclude = ('vrf',) - # Related IP table - related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\ - .filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)) - related_ips_table = tables.IPAddressBriefTable(list(related_ips)) + # Duplicate IPs table + duplicate_ips = IPAddress.objects.filter( + vrf=ipaddress.vrf, address=str(ipaddress.address) + ).exclude( + pk=ipaddress.pk + ).select_related( + 'interface__device', 'nat_inside' + ) + duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) - return render(request, 'ipam/ipaddress.html', { - 'ipaddress': ipaddress, - 'parent_prefixes_table': parent_prefixes_table, - 'duplicate_ips_table': duplicate_ips_table, - 'related_ips_table': related_ips_table, - }) + # Related IP table + related_ips = IPAddress.objects.select_related( + 'interface__device' + ).exclude( + address=str(ipaddress.address) + ).filter( + vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) + ) + related_ips_table = tables.IPAddressTable(list(related_ips), orderable=False) + + return render(request, 'ipam/ipaddress.html', { + 'ipaddress': ipaddress, + 'parent_prefixes_table': parent_prefixes_table, + 'duplicate_ips_table': duplicate_ips_table, + 'related_ips_table': related_ips_table, + }) -@permission_required(['dcim.change_device', 'ipam.change_ipaddress']) -def ipaddress_assign(request, pk): - - ipaddress = get_object_or_404(IPAddress, pk=pk) - - if request.method == 'POST': - form = forms.IPAddressAssignForm(request.POST) - if form.is_valid(): - - interface = form.cleaned_data['interface'] - ipaddress.interface = interface - ipaddress.save() - messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface)) - - if form.cleaned_data['set_as_primary']: - device = interface.device - if ipaddress.family == 4: - device.primary_ip4 = ipaddress - elif ipaddress.family == 6: - device.primary_ip6 = ipaddress - device.save() - - return redirect('ipam:ipaddress', pk=ipaddress.pk) - else: - assert False, form.errors - - else: - form = forms.IPAddressAssignForm() - - return render(request, 'ipam/ipaddress_assign.html', { - 'ipaddress': ipaddress, - 'form': form, - 'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}), - }) - - -@permission_required(['dcim.change_device', 'ipam.change_ipaddress']) -def ipaddress_remove(request, pk): - - ipaddress = get_object_or_404(IPAddress, pk=pk) - - if request.method == 'POST': - form = ConfirmationForm(request.POST) - if form.is_valid(): - - device = ipaddress.interface.device - ipaddress.interface = None - ipaddress.save() - messages.success(request, u"Removed IP address {} from {}.".format(ipaddress, device)) - - if device.primary_ip4 == ipaddress.pk: - device.primary_ip4 = None - device.save() - elif device.primary_ip6 == ipaddress.pk: - device.primary_ip6 = None - device.save() - - return redirect('ipam:ipaddress', pk=ipaddress.pk) - - else: - form = ConfirmationForm() - - return render(request, 'ipam/ipaddress_unassign.html', { - 'ipaddress': ipaddress, - 'form': form, - 'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}), - }) - - -class IPAddressEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_ipaddress' +class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_ipaddress' model = IPAddress form_class = forms.IPAddressForm - fields_initial = ['address', 'vrf'] template_name = 'ipam/ipaddress_edit.html' default_return_url = 'ipam:ipaddress_list' +class IPAddressEditView(IPAddressCreateView): + permission_required = 'ipam.change_ipaddress' + + class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_ipaddress' model = IPAddress default_return_url = 'ipam:ipaddress_list' -class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView): +class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView): permission_required = 'ipam.add_ipaddress' - form = forms.IPAddressBulkAddForm - model = IPAddress + pattern_form = forms.IPAddressPatternForm + model_form = forms.IPAddressBulkAddForm + pattern_target = 'address' template_name = 'ipam/ipaddress_bulk_add.html' default_return_url = 'ipam:ipaddress_list' class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_ipaddress' - form = forms.IPAddressImportForm + model_form = forms.IPAddressCSVForm table = tables.IPAddressTable - template_name = 'ipam/ipaddress_import.html' default_return_url = 'ipam:ipaddress_list' - def save_obj(self, obj): - obj.save() - - # Update primary IP for device if needed. The Device must be updated directly in the database; otherwise we risk - # overwriting a previous IP assignment from the same import (see #861). - try: - if obj.family == 4 and obj.primary_ip4_for: - Device.objects.filter(pk=obj.primary_ip4_for.pk).update(primary_ip4=obj) - elif obj.family == 6 and obj.primary_ip6_for: - Device.objects.filter(pk=obj.primary_ip6_for.pk).update(primary_ip6=obj) - except Device.DoesNotExist: - pass - class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_ipaddress' cls = IPAddress + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') filter = filters.IPAddressFilter + table = tables.IPAddressTable form = forms.IPAddressBulkEditForm - template_name = 'ipam/ipaddress_bulk_edit.html' default_return_url = 'ipam:ipaddress_list' class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_ipaddress' cls = IPAddress + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') filter = filters.IPAddressFilter + table = tables.IPAddressTable default_return_url = 'ipam:ipaddress_list' @@ -713,19 +709,25 @@ class VLANGroupListView(ObjectListView): template_name = 'ipam/vlangroup_list.html' -class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_vlangroup' +class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_vlangroup' model = VLANGroup form_class = forms.VLANGroupForm - def get_return_url(self, obj): + def get_return_url(self, request, obj): return reverse('ipam:vlangroup_list') +class VLANGroupEditView(VLANGroupCreateView): + permission_required = 'ipam.change_vlangroup' + + class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlangroup' cls = VLANGroup + queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) filter = filters.VLANGroupFilter + table = tables.VLANGroupTable default_return_url = 'ipam:vlangroup_list' @@ -737,31 +739,39 @@ class VLANListView(ObjectListView): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') filter = filters.VLANFilter filter_form = forms.VLANFilterForm - table = tables.VLANTable + table = tables.VLANDetailTable template_name = 'ipam/vlan_list.html' -def vlan(request, pk): +class VLANView(View): - vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk) - prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role') - prefix_table = tables.PrefixBriefTable(list(prefixes)) - prefix_table.exclude = ('vlan',) + def get(self, request, pk): - return render(request, 'ipam/vlan.html', { - 'vlan': vlan, - 'prefix_table': prefix_table, - }) + vlan = get_object_or_404(VLAN.objects.select_related( + 'site__region', 'tenant__group', 'role' + ), pk=pk) + prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role') + prefix_table = tables.PrefixTable(list(prefixes), orderable=False) + prefix_table.exclude = ('vlan',) + + return render(request, 'ipam/vlan.html', { + 'vlan': vlan, + 'prefix_table': prefix_table, + }) -class VLANEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_vlan' +class VLANCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_vlan' model = VLAN form_class = forms.VLANForm template_name = 'ipam/vlan_edit.html' default_return_url = 'ipam:vlan_list' +class VLANEditView(VLANCreateView): + permission_required = 'ipam.change_vlan' + + class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_vlan' model = VLAN @@ -770,25 +780,27 @@ class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_vlan' - form = forms.VLANImportForm + model_form = forms.VLANCSVForm table = tables.VLANTable - template_name = 'ipam/vlan_import.html' default_return_url = 'ipam:vlan_list' class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vlan' cls = VLAN + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter + table = tables.VLANTable form = forms.VLANBulkEditForm - template_name = 'ipam/vlan_bulk_edit.html' default_return_url = 'ipam:vlan_list' class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlan' cls = VLAN + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter + table = tables.VLANTable default_return_url = 'ipam:vlan_list' @@ -796,8 +808,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Services # -class ServiceEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'ipam.change_service' +class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.add_service' model = Service form_class = forms.ServiceForm template_name = 'ipam/service_edit.html' @@ -807,10 +819,14 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView): obj.device = get_object_or_404(Device, pk=url_kwargs['device']) return obj - def get_return_url(self, obj): + def get_return_url(self, request, obj): return obj.device.get_absolute_url() +class ServiceEditView(ServiceCreateView): + permission_required = 'ipam.change_service' + + class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_service' model = Service diff --git a/netbox/media/image-attachments/.gitignore b/netbox/media/image-attachments/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/netbox/media/image-attachments/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index b85fcafbb..2e08090c7 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -38,6 +38,31 @@ 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$', +] + +# Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal +# sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging +# on a production system. +DEBUG = False + # Email settings EMAIL = { 'SERVER': 'localhost', @@ -48,24 +73,37 @@ 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 + +# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs: +# https://docs.djangoproject.com/en/1.11/topics/logging/ +LOGGING = {} + # 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. +# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. +# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request +# all objects by specifying "?limit=0". +MAX_PAGE_SIZE = 1000 + +# 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 +115,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 diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py new file mode 100644 index 000000000..85343ec77 --- /dev/null +++ b/netbox/netbox/forms.py @@ -0,0 +1,42 @@ +from __future__ import unicode_literals + +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' + ) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 12377f9af..28d98acf1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -8,43 +8,52 @@ 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.4-r1' +VERSION = '2.1.0' -# Import local configuration +# Import required configuration parameters +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 +# Import optional configuration parameters ADMINS = getattr(configuration, 'ADMINS', []) -DEBUG = getattr(configuration, 'DEBUG', False) -EMAIL = getattr(configuration, 'EMAIL', {}) -LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) +BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False) +BANNER_TOP = getattr(configuration, 'BANNER_TOP', False) BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only +CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) +CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) +CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) +DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') +DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') +DEBUG = getattr(configuration, 'DEBUG', False) +ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) +EMAIL = getattr(configuration, 'EMAIL', {}) +LOGGING = getattr(configuration, 'LOGGING', {}) +LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) +MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) +PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '') NETBOX_PASSWORD = getattr(configuration, 'NETBOX_PASSWORD', '') -TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') -DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') -TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') -SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') -DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') -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) +SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') +TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') +TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') + CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS # Attempt to import LDAP configuration if it has been defined @@ -73,8 +82,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,7 +113,9 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + 'corsheaders', 'debug_toolbar', + 'django_filters', 'django_tables2', 'mptt', 'rest_framework', @@ -120,6 +133,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 +143,7 @@ MIDDLEWARE = ( 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', 'utilities.middleware.LoginRequiredMiddleware', + 'utilities.middleware.APIVersionMiddleware', ) ROOT_URLCONF = 'netbox.urls' @@ -142,6 +157,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 +172,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_ROOT = os.path.join(BASE_DIR, 'media') +MEDIA_URL = '/{}media/'.format(BASE_PATH) + # 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 +201,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': 'utilities.api.OptionalLimitOffsetPagination', + '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', diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 9b1f81dad..ddddf27a2 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,44 +1,56 @@ +from __future__ import unicode_literals + +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 users.views import login, logout +from netbox.views import APIRootView, handle_500, HomeView, SearchView, trigger_500 +from users.views import LoginView, LogoutView handler500 = handle_500 +swagger_view = get_swagger_view(title='NetBox API') _patterns = [ - # Default page - url(r'^$', home, name='home'), + # Base views + url(r'^$', HomeView.as_view(), name='home'), + url(r'^search/$', SearchView.as_view(), name='search'), # Login/logout - url(r'^login/$', login, name='login'), - url(r'^logout/$', logout, name='logout'), + url(r'^login/$', LoginView.as_view(), name='login'), + url(r'^logout/$', LogoutView.as_view(), 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/docs/', include('rest_framework_swagger.urls')), - url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), + 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'), + + # Serving static media in Django to pipe it through LoginRequiredMiddleware + url(r'^media/(?P.*)$', 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), ] diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 7aa144295..d5224b462 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,50 +1,229 @@ +from __future__ import unicode_literals +from collections import OrderedDict import sys +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.reverse import reverse + from django.shortcuts import render +from django.views.generic import View -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 circuits.filters import CircuitFilter, ProviderFilter +from circuits.models import Circuit, Provider +from circuits.tables import CircuitTable, ProviderTable +from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter +from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site +from dcim.tables import DeviceTable, DeviceTypeTable, RackTable, SiteTable +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 AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable +from secrets.filters import SecretFilter from secrets.models import Secret +from secrets.tables import SecretTable +from tenancy.filters import TenantFilter from tenancy.models import Tenant +from tenancy.tables import TenantTable +from .forms import SearchForm -def home(request): +SEARCH_MAX_RESULTS = 15 +SEARCH_TYPES = OrderedDict(( + # Circuits + ('provider', { + 'queryset': Provider.objects.all(), + 'filter': ProviderFilter, + 'table': ProviderTable, + 'url': 'circuits:provider_list', + }), + ('circuit', { + 'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'), + 'filter': CircuitFilter, + 'table': CircuitTable, + 'url': 'circuits:circuit_list', + }), + # DCIM + ('site', { + 'queryset': Site.objects.select_related('region', 'tenant'), + 'filter': SiteFilter, + 'table': SiteTable, + 'url': 'dcim:site_list', + }), + ('rack', { + 'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'), + 'filter': RackFilter, + 'table': RackTable, + 'url': 'dcim:rack_list', + }), + ('devicetype', { + 'queryset': DeviceType.objects.select_related('manufacturer'), + 'filter': DeviceTypeFilter, + 'table': DeviceTypeTable, + 'url': 'dcim:devicetype_list', + }), + ('device', { + 'queryset': Device.objects.select_related( + 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack' + ), + 'filter': DeviceFilter, + 'table': DeviceTable, + 'url': 'dcim:device_list', + }), + # IPAM + ('vrf', { + 'queryset': VRF.objects.select_related('tenant'), + 'filter': VRFFilter, + 'table': VRFTable, + 'url': 'ipam:vrf_list', + }), + ('aggregate', { + 'queryset': Aggregate.objects.select_related('rir'), + 'filter': AggregateFilter, + 'table': AggregateTable, + 'url': 'ipam:aggregate_list', + }), + ('prefix', { + 'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), + 'filter': PrefixFilter, + 'table': PrefixTable, + 'url': 'ipam:prefix_list', + }), + ('ipaddress', { + 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), + 'filter': IPAddressFilter, + 'table': IPAddressTable, + 'url': 'ipam:ipaddress_list', + }), + ('vlan', { + 'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'), + 'filter': VLANFilter, + 'table': VLANTable, + 'url': 'ipam:vlan_list', + }), + # Secrets + ('secret', { + 'queryset': Secret.objects.select_related('role', 'device'), + 'filter': SecretFilter, + 'table': SecretTable, + 'url': 'secrets:secret_list', + }), + # Tenancy + ('tenant', { + 'queryset': Tenant.objects.select_related('group'), + 'filter': TenantFilter, + 'table': TenantTable, + 'url': 'tenancy:tenant_list', + }), +)) - stats = { - # Organization - 'site_count': Site.objects.count(), - 'tenant_count': Tenant.objects.count(), +class HomeView(View): + template_name = 'home.html' - # DCIM - 'rack_count': Rack.objects.count(), - 'device_count': Device.objects.count(), - 'interface_connections_count': InterfaceConnection.objects.count(), - 'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(), - 'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(), + def get(self, request): - # IPAM - 'vrf_count': VRF.objects.count(), - 'aggregate_count': Aggregate.objects.count(), - 'prefix_count': Prefix.objects.count(), - 'ipaddress_count': IPAddress.objects.count(), - 'vlan_count': VLAN.objects.count(), + stats = { - # Circuits - 'provider_count': Provider.objects.count(), - 'circuit_count': Circuit.objects.count(), + # Organization + 'site_count': Site.objects.count(), + 'tenant_count': Tenant.objects.count(), - # Secrets - 'secret_count': Secret.objects.count(), + # DCIM + 'rack_count': Rack.objects.count(), + 'device_count': Device.objects.count(), + 'interface_connections_count': InterfaceConnection.objects.count(), + 'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(), + 'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(), - } + # IPAM + 'vrf_count': VRF.objects.count(), + 'aggregate_count': Aggregate.objects.count(), + 'prefix_count': Prefix.objects.count(), + 'ipaddress_count': IPAddress.objects.count(), + 'vlan_count': VLAN.objects.count(), - return render(request, 'home.html', { - 'stats': stats, - 'recent_activity': UserAction.objects.select_related('user')[:50] - }) + # Circuits + 'provider_count': Provider.objects.count(), + 'circuit_count': Circuit.objects.count(), + + # Secrets + 'secret_count': Secret.objects.count(), + + } + + return render(request, self.template_name, { + '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, orderable=False) + 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 "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): @@ -62,5 +241,6 @@ def trigger_500(request): """ Hot-wired method of triggering a server error to test reporting """ - raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional " - "person you are.") + raise Exception( + "Congratulations, you've triggered an exception! Go tell all your friends what an exceptional person you are." + ) diff --git a/netbox/netbox/wsgi.py b/netbox/netbox/wsgi.py index 7fac23c61..6d13ffe9d 100644 --- a/netbox/netbox/wsgi.py +++ b/netbox/netbox/wsgi.py @@ -11,6 +11,7 @@ import os from django.core.wsgi import get_wsgi_application + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") application = get_wsgi_application() diff --git a/netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css.map b/netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css.map deleted file mode 100644 index 2c6b65afc..000000000 --- a/netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap-theme.min.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css.map b/netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css.map deleted file mode 100644 index 09f8cda78..000000000 --- a/netbox/project-static/bootstrap-3.3.6-dist/css/bootstrap.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["bootstrap.css","less/normalize.less","less/print.less","less/glyphicons.less","less/scaffolding.less","less/mixins/vendor-prefixes.less","less/mixins/tab-focus.less","less/mixins/image.less","less/type.less","less/mixins/text-emphasis.less","less/mixins/background-variant.less","less/mixins/text-overflow.less","less/code.less","less/grid.less","less/mixins/grid.less","less/mixins/grid-framework.less","less/tables.less","less/mixins/table-row.less","less/forms.less","less/mixins/forms.less","less/buttons.less","less/mixins/buttons.less","less/mixins/opacity.less","less/component-animations.less","less/dropdowns.less","less/mixins/nav-divider.less","less/mixins/reset-filter.less","less/button-groups.less","less/mixins/border-radius.less","less/input-groups.less","less/navs.less","less/navbar.less","less/mixins/nav-vertical-align.less","less/utilities.less","less/breadcrumbs.less","less/pagination.less","less/mixins/pagination.less","less/pager.less","less/labels.less","less/mixins/labels.less","less/badges.less","less/jumbotron.less","less/thumbnails.less","less/alerts.less","less/mixins/alerts.less","less/progress-bars.less","less/mixins/gradients.less","less/mixins/progress-bar.less","less/media.less","less/list-group.less","less/mixins/list-group.less","less/panels.less","less/mixins/panels.less","less/responsive-embed.less","less/wells.less","less/close.less","less/modals.less","less/tooltip.less","less/mixins/reset-text.less","less/popovers.less","less/carousel.less","less/mixins/clearfix.less","less/mixins/center-block.less","less/mixins/hide-text.less","less/responsive-utilities.less","less/mixins/responsive-visibility.less"],"names":[],"mappings":"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AW,iBAAA;CHsR9C;AGrRmC;EAAW,iBAAA;CHwR9C;AGvRmC;EAAW,iBAAA;CH0R9C;AGzRmC;EAAW,iBAAA;CH4R9C;AG3RmC;EAAW,iBAAA;CH8R9C;AG7RmC;EAAW,iBAAA;CHgS9C;AG/RmC;EAAW,iBAAA;CHkS9C;AGjSmC;EAAW,iBAAA;CHoS9C;AGnSmC;EAAW,iBAAA;CHsS9C;AGrSmC;EAAW,iBAAA;CHwS9C;AGvSmC;EAAW,iBAAA;CH0S9C;AGzSmC;EAAW,iBAAA;CH4S9C;AG3SmC;EAAW,iBAAA;CH8S9C;AG7SmC;EAAW,iBAAA;CHgT9C;AG/SmC;EAAW,iBAAA;CHkT9C;AGjTmC;EAAW,iBAAA;CHoT9C;AGnTmC;EAAW,iBAAA;CHsT9C;AGrTmC;EAAW,iBAAA;CHwT9C;AGvTmC;EAAW,iBAAA;CH0T9C;AGzTmC;EAAW,iBAAA;CH4T9C;AG3TmC;EAAW,iBAAA;CH8T9C;AG7TmC;EAAW,iBAAA;CHgU9C;AG/TmC;EAAW,iBAAA;CHkU9C;AGjUmC;EAAW,iBAAA;CHoU9C;AGnUmC;EAAW,iBAAA;CHsU9C;AGrUmC;EAAW,iBAAA;CHwU9C;AGvUmC;EAAW,iBAAA;CH0U9C;AGzUmC;EAAW,iBAAA;CH4U9C;AG3UmC;EAAW,iBAAA;CH8U9C;AG7UmC;EAAW,iBAAA;CHgV9C;AG/UmC;EAAW,iBAAA;CHkV9C;AGjVmC;EAAW,iBAAA;CHoV9C;AGnVmC;EAAW,iBAAA;CHsV9C;AGrVmC;EAAW,iBAAA;CHwV9C;AGvVmC;EAAW,iBAAA;CH0V9C;AGzVmC;EAAW,iBAAA;CH4V9C;AG3VmC;EAAW,iBAAA;CH8V9C;AG7VmC;EAAW,iBAAA;CHgW9C;AG/VmC;EAAW,iBAAA;CHkW9C;AGjWmC;EAAW,iBAAA;CHoW9C;AGnWmC;EAAW,iBAAA;CHsW9C;AGrWmC;EAAW,iBAAA;CHwW9C;AGvWmC;EAAW,iBAAA;CH0W9C;AGzWmC;EAAW,iBAAA;CH4W9C;AG3WmC;EAAW,iBAAA;CH8W9C;AG7WmC;EAAW,iBAAA;CHgX9C;AG/WmC;EAAW,iBAAA;CHkX9C;AGjXmC;EAAW,iBAAA;CHoX9C;AGnXmC;EAAW,iBAAA;CHsX9C;AGrXmC;EAAW,iBAAA;CHwX9C;AGvXmC;EAAW,iBAAA;CH0X9C;AGzXmC;EAAW,iBAAA;CH4X9C;AG3XmC;EAAW,iBAAA;CH8X9C;AG7XmC;EAAW,iBAAA;CHgY9C;AG/XmC;EAAW,iBAAA;CHkY9C;AGjYmC;EAAW,iBAAA;CHoY9C;AGnYmC;EAAW,iBAAA;CHsY9C;AGrYmC;EAAW,iBAAA;CHwY9C;AGvYmC;EAAW,iBAAA;CH0Y9C;AGzYmC;EAAW,iBAAA;CH4Y9C;AG3YmC;EAAW,iBAAA;CH8Y9C;AG7YmC;EAAW,iBAAA;CHgZ9C;AG/YmC;EAAW,iBAAA;CHkZ9C;AGjZmC;EAAW,iBAAA;CHoZ9C;AGnZmC;EAAW,iBAAA;CHsZ9C;AGrZmC;EAAW,iBAAA;CHwZ9C;AGvZmC;EAAW,iBAAA;CH0Z9C;AGzZmC;EAAW,iBAAA;CH4Z9C;AG3ZmC;EAAW,iBAAA;CH8Z9C;AG7ZmC;EAAW,iBAAA;CHga9C;AG/ZmC;EAAW,iBAAA;CHka9C;AGjamC;EAAW,iBAAA;CHoa9C;AGnamC;EAAW,iBAAA;CHsa9C;AGramC;EAAW,iBAAA;CHwa9C;AGvamC;EAAW,iBAAA;CH0a9C;AGzamC;EAAW,iBAAA;CH4a9C;AG3amC;EAAW,iBAAA;CH8a9C;AG7amC;EAAW,iBAAA;CHgb9C;AG/amC;EAAW,iBAAA;CHkb9C;AGjbmC;EAAW,iBAAA;CHob9C;AGnbmC;EAAW,iBAAA;CHsb9C;AGrbmC;EAAW,iBAAA;CHwb9C;AGvbmC;EAAW,iBAAA;CH0b9C;AGzbmC;EAAW,iBAAA;CH4b9C;AG3bmC;EAAW,iBAAA;CH8b9C;AG7bmC;EAAW,iBAAA;CHgc9C;AG/bmC;EAAW,iBAAA;CHkc9C;AGjcmC;EAAW,iBAAA;CHoc9C;AGncmC;EAAW,iBAAA;CHsc9C;AGrcmC;EAAW,iBAAA;CHwc9C;AGvcmC;EAAW,iBAAA;CH0c9C;AGzcmC;EAAW,iBAAA;CH4c9C;AG3cmC;EAAW,iBAAA;CH8c9C;AG7cmC;EAAW,iBAAA;CHgd9C;AG/cmC;EAAW,iBAAA;CHkd9C;AGjdmC;EAAW,iBAAA;CHod9C;AGndmC;EAAW,iBAAA;CHsd9C;AGrdmC;EAAW,iBAAA;CHwd9C;AGvdmC;EAAW,iBAAA;CH0d9C;AGzdmC;EAAW,iBAAA;CH4d9C;AG3dmC;EAAW,iBAAA;CH8d9C;AG7dmC;EAAW,iBAAA;CHge9C;AG/dmC;EAAW,iBAAA;CHke9C;AGjemC;EAAW,iBAAA;CHoe9C;AGnemC;EAAW,iBAAA;CHse9C;AGremC;EAAW,iBAAA;CHwe9C;AGvemC;EAAW,iBAAA;CH0e9C;AGzemC;EAAW,iBAAA;CH4e9C;AG3emC;EAAW,iBAAA;CH8e9C;AG7emC;EAAW,iBAAA;CHgf9C;AG/emC;EAAW,iBAAA;CHkf9C;AGjfmC;EAAW,iBAAA;CHof9C;AGnfmC;EAAW,iBAAA;CHsf9C;AGrfmC;EAAW,iBAAA;CHwf9C;AGvfmC;EAAW,iBAAA;CH0f9C;AGzfmC;EAAW,iBAAA;CH4f9C;AG3fmC;EAAW,iBAAA;CH8f9C;AG7fmC;EAAW,iBAAA;CHggB9C;AG/fmC;EAAW,iBAAA;CHkgB9C;AGjgBmC;EAAW,iBAAA;CHogB9C;AGngBmC;EAAW,iBAAA;CHsgB9C;AGrgBmC;EAAW,iBAAA;CHwgB9C;AGvgBmC;EAAW,iBAAA;CH0gB9C;AGzgBmC;EAAW,iBAAA;CH4gB9C;AG3gBmC;EAAW,iBAAA;CH8gB9C;AG7gBmC;EAAW,iBAAA;CHghB9C;AG/gBmC;EAAW,iBAAA;CHkhB9C;AGjhBmC;EAAW,iBAAA;CHohB9C;AGnhBmC;EAAW,iBAAA;CHshB9C;AGrhBmC;EAAW,iBAAA;CHwhB9C;AGvhBmC;EAAW,iBAAA;CH0hB9C;AGzhBmC;EAAW,iBAAA;CH4hB9C;AG3hBmC;EAAW,iBAAA;CH8hB9C;AG7hBmC;EAAW,iBAAA;CHgiB9C;AG/hBmC;EAAW,iBAAA;CHkiB9C;AGjiBmC;EAAW,iBAAA;CHoiB9C;AGniBmC;EAAW,iBAAA;CHsiB9C;AGriBmC;EAAW,iBAAA;CHwiB9C;AGviBmC;EAAW,iBAAA;CH0iB9C;AGziBmC;EAAW,iBAAA;CH4iB9C;AG3iBmC;EAAW,iBAAA;CH8iB9C;AG7iBmC;EAAW,iBAAA;CHgjB9C;AG/iBmC;EAAW,iBAAA;CHkjB9C;AGjjBmC;EAAW,iBAAA;CHojB9C;AGnjBmC;EAAW,iBAAA;CHsjB9C;AGrjBmC;EAAW,iBAAA;CHwjB9C;AGvjBmC;EAAW,iBAAA;CH0jB9C;AGzjBmC;EAAW,iBAAA;CH4jB9C;AG3jBmC;EAAW,iBAAA;CH8jB9C;AG7jBmC;EAAW,iBAAA;CHgkB9C;AG/jBmC;EAAW,iBAAA;CHkkB9C;AGjkBmC;EAAW,iBAAA;CHokB9C;AGnkBmC;EAAW,iBAAA;CHskB9C;AGrkBmC;EAAW,iBAAA;CHwkB9C;AGvkBmC;EAAW,iBAAA;CH0kB9C;AGzkBmC;EAAW,iBAAA;CH4kB9C;AG3kBmC;EAAW,iBAAA;CH8kB9C;AG7kBmC;EAAW,iBAAA;CHglB9C;AG/kBmC;EAAW,iBAAA;CHklB9C;AGjlBmC;EAAW,iBAAA;CHolB9C;AGnlBmC;EAAW,iBAAA;CHslB9C;AGrlBmC;EAAW,iBAAA;CHwlB9C;AGvlBmC;EAAW,iBAAA;CH0lB9C;AGzlBmC;EAAW,iBAAA;CH4lB9C;AG3lBmC;EAAW,iBAAA;CH8lB9C;AG7lBmC;EAAW,iBAAA;CHgmB9C;AG/lBmC;EAAW,iBAAA;CHkmB9C;AGjmBmC;EAAW,iBAAA;CHomB9C;AGnmBmC;EAAW,iBAAA;CHsmB9C;AGrmBmC;EAAW,iBAAA;CHwmB9C;AGvmBmC;EAAW,iBAAA;CH0mB9C;AGzmBmC;EAAW,iBAAA;CH4mB9C;AG3mBmC;EAAW,iBAAA;CH8mB9C;AG7mBmC;EAAW,iBAAA;CHgnB9C;AG/mBmC;EAAW,iBAAA;CHknB9C;AGjnBmC;EAAW,iBAAA;CHonB9C;AGnnBmC;EAAW,iBAAA;CHsnB9C;AGrnBmC;EAAW,iBAAA;CHwnB9C;AGvnBmC;EAAW,iBAAA;CH0nB9C;AGznBmC;EAAW,iBAAA;CH4nB9C;AG3nBmC;EAAW,iBAAA;CH8nB9C;AG7nBmC;EAAW,iBAAA;CHgoB9C;AG/nBmC;EAAW,iBAAA;CHkoB9C;AGjoBmC;EAAW,iBAAA;CHooB9C;AGnoBmC;EAAW,iBAAA;CHsoB9C;AGroBmC;EAAW,iBAAA;CHwoB9C;AG/nBmC;EAAW,iBAAA;CHkoB9C;AGjoBmC;EAAW,iBAAA;CHooB9C;AGnoBmC;EAAW,iBAAA;CHsoB9C;AGroBmC;EAAW,iBAAA;CHwoB9C;AGvoBmC;EAAW,iBAAA;CH0oB9C;AGzoBmC;EAAW,iBAAA;CH4oB9C;AG3oBmC;EAAW,iBAAA;CH8oB9C;AG7oBmC;EAAW,iBAAA;CHgpB9C;AG/oBmC;EAAW,iBAAA;CHkpB9C;AGjpBmC;EAAW,iBAAA;CHopB9C;AGnpBmC;EAAW,iBAAA;CHspB9C;AGrpBmC;EAAW,iBAAA;CHwpB9C;AGvpBmC;EAAW,iBAAA;CH0pB9C;AGzpBmC;EAAW,iBAAA;CH4pB9C;AG3pBmC;EAAW,iBAAA;CH8pB9C;AG7pBmC;EAAW,iBAAA;CHgqB9C;AG/pBmC;EAAW,iBAAA;CHkqB9C;AGjqBmC;EAAW,iBAAA;CHoqB9C;AGnqBmC;EAAW,iBAAA;CHsqB9C;AGrqBmC;EAAW,iBAAA;CHwqB9C;AGvqBmC;EAAW,iBAAA;CH0qB9C;AGzqBmC;EAAW,iBAAA;CH4qB9C;AG3qBmC;EAAW,iBAAA;CH8qB9C;AG7qBmC;EAAW,iBAAA;CHgrB9C;AG/qBmC;EAAW,iBAAA;CHkrB9C;AGjrBmC;EAAW,iBAAA;CHorB9C;AGnrBmC;EAAW,iBAAA;CHsrB9C;AGrrBmC;EAAW,iBAAA;CHwrB9C;AGvrBmC;EAAW,iBAAA;CH0rB9C;AGzrBmC;EAAW,iBAAA;CH4rB9C;AG3rBmC;EAAW,iBAAA;CH8rB9C;AG7rBmC;EAAW,iBAAA;CHgsB9C;AG/rBmC;EAAW,iBAAA;CHksB9C;AGjsBmC;EAAW,iBAAA;CHosB9C;AGnsBmC;EAAW,iBAAA;CHssB9C;AGrsBmC;EAAW,iBAAA;CHwsB9C;AGvsBmC;EAAW,iBAAA;CH0sB9C;AGzsBmC;EAAW,iBAAA;CH4sB9C;AG3sBmC;EAAW,iBAAA;CH8sB9C;AG7sBmC;EAAW,iBAAA;CHgtB9C;AG/sBmC;EAAW,iBAAA;CHktB9C;AGjtBmC;EAAW,iBAAA;CHotB9C;AGntBmC;EAAW,iBAAA;CHstB9C;AGrtBmC;EAAW,iBAAA;CHwtB9C;AGvtBmC;EAAW,iBAAA;CH0tB9C;AGztBmC;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CE,oBAAA;CfwkDH;AezkDC;EACE,oBAAA;Cf2kDH;Ae5kDC;EACE,WAAA;Cf8kDH;Ae/kDC;EACE,oBAAA;CfilDH;AellDC;EACE,mBAAA;CfolDH;AehlDC;EACE,YAAA;CfklDH;AelmDC;EACE,WAAA;CfomDH;AermDC;EACE,mBAAA;CfumDH;AexmDC;EACE,mBAAA;Cf0mDH;Ae3mDC;EACE,UAAA;Cf6mDH;Ae9mDC;EACE,mBAAA;CfgnDH;AejnDC;EACE,mBAAA;CfmnDH;AepnDC;EACE,UAAA;CfsnDH;AevnDC;EACE,mBAAA;CfynDH;Ae1nDC;EACE,mBAAA;Cf4nDH;Ae7nDC;EACE,UAAA;Cf+nDH;AehoDC;EACE,mBAAA;CfkoDH;AenoDC;EACE,kBAAA;CfqoDH;AejoDC;EACE,WAAA;CfmoDH;AernDC;EACE,kBAAA;CfunDH;AexnDC;EACE,0BAAA;Cf0nDH;Ae3nDC;EACE,0BAAA;Cf6nDH;Ae9nDC;EACE,iBAAA;CfgoDH;AejoDC;EACE,0BAAA;CfmoDH;AepoDC;EACE,0BAAA;CfsoDH;AevoDC;EACE,iBAAA;CfyoDH;Ae1oDC;EACE,0BAAA;Cf4oDH;Ae7oDC;EACE,0BAAA;Cf+oDH;AehpDC;EACE,iBAAA;CfkpDH;AenpDC;EACE,0BAAA;CfqpDH;AetpDC;EACE,yBAAA;CfwpDH;AezpDC;EACE,gBAAA;Cf2pDH;Aa3pDD;EElCI;IACE,YAAA;GfgsDH;EezrDD;IACE,YAAA;Gf2rDD;Ee5rDD;IACE,oBAAA;Gf8rDD;Ee/rDD;IACE,oBAAA;GfisDD;EelsDD;IACE,WAAA;GfosDD;EersDD;IACE,oBAAA;GfusDD;EexsDD;IACE,oBAAA;Gf0sDD;Ee3sDD;IACE,WAAA;Gf6sDD;Ee9sDD;IACE,oBAAA;GfgtDD;EejtDD;IACE,oBAAA;GfmtDD;EeptDD;IACE,WAAA;GfstDD;EevtDD;IACE,oBAAA;GfytDD;Ee1tDD;IACE,mBAAA;Gf4tDD;Ee9sDD;IACE,YAAA;GfgtDD;EejtDD;IACE,oBAAA;GfmtDD;EeptDD;IACE,oBAAA;GfstDD;EevtDD;IACE,WAAA;GfytDD;Ee1tDD;IACE,oBAAA;Gf4tDD;Ee7tDD;IACE,oBAAA;Gf+tDD;EehuDD;IACE,WAAA;GfkuDD;EenuDD;IACE,oBAAA;GfquDD;EetuDD;IACE,oBAAA;GfwuDD;EezuDD;IACE,WAAA;Gf2uDD;Ee5uDD;IACE,oBAAA;Gf8uDD;Ee/uDD;IACE,mBAAA;GfivDD;Ee7uDD;IACE,YAAA;Gf+uDD;Ee/vDD;IACE,WAAA;GfiwDD;EelwDD;IACE,mBAAA;GfowDD;EerwDD;IACE,mBAAA;GfuwDD;EexwDD;IACE,UAAA;Gf0wDD;Ee3wDD;IACE,mBAAA;Gf6wDD;Ee9wDD;IACE,mBAAA;GfgxDD;EejxDD;IACE,UAAA;GfmxDD;EepxDD;IACE,mBAAA;GfsxDD;EevxDD;IACE,mBAAA;GfyxDD;Ee1xDD;IACE,UAAA;Gf4xDD;Ee7xDD;IACE,mBAAA;Gf+xDD;EehyDD;IACE,kBAAA;GfkyDD;Ee9xDD;IACE,WAAA;GfgyDD;EelxDD;IACE,kBAAA;GfoxDD;EerxDD;IACE,0BAAA;GfuxDD;EexxDD;IACE,0BAAA;Gf0xDD;Ee3xDD;IACE,iBAAA;Gf6xDD;Ee9xDD;IACE,0BAAA;GfgyDD;EejyDD;IACE,0BAAA;GfmyDD;EepyDD;IACE,iBAAA;GfsyDD;EevyDD;IACE,0BAAA;GfyyDD;Ee1yDD;IACE,0BAAA;Gf4yDD;Ee7yDD;IACE,iBAAA;Gf+yDD;EehzDD;IACE,0BAAA;GfkzDD;EenzDD;IACE,yBAAA;GfqzDD;EetzDD;IACE,gBAAA;GfwzDD;CACF;AahzDD;EE3CI;IACE,YAAA;Gf81DH;Eev1DD;IACE,YAAA;Gfy1DD;Ee11DD;IACE,oBAAA;Gf41DD;Ee71DD;IACE,oBAAA;Gf+1DD;Eeh2DD;IACE,WAAA;Gfk2DD;Een2DD;IACE,oBAAA;Gfq2DD;Eet2DD;IACE,oBAAA;Gfw2DD;Eez2DD;IACE,WAAA;Gf22DD;Ee52DD;IACE,oBAAA;Gf82DD;Ee/2DD;IACE,oBAAA;Gfi3DD;Eel3DD;IACE,WAAA;Gfo3DD;Eer3DD;IACE,oBAAA;Gfu3DD;Eex3DD;IACE,mBAAA;Gf03DD;Ee52DD;IACE,YAAA;Gf82DD;Ee/2DD;IACE,oBAAA;Gfi3DD;Eel3DD;IACE,oBAAA;Gfo3DD;Eer3DD;IACE,WAAA;Gfu3DD;Eex3DD;IACE,oBAAA;Gf03DD;Ee33DD;IACE,oBAAA;Gf63DD;Ee93DD;IACE,WAAA;Gfg4DD;Eej4DD;IACE,oBAAA;Gfm4DD;Eep4DD;IACE,oBAAA;Gfs4DD;Eev4DD;IACE,WAAA;Gfy4DD;Ee14DD;IACE,oBAAA;Gf44DD;Ee74DD;IACE,mBAAA;Gf+4DD;Ee34DD;IACE,YAAA;Gf64DD;Ee75DD;IACE,WAAA;Gf+5DD;Eeh6DD;IACE,mBAAA;Gfk6DD;Een6DD;IACE,mBAAA;Gfq6DD;Eet6DD;IACE,UAAA;Gfw6DD;Eez6DD;IACE,mBAAA;Gf26DD;Ee56DD;IACE,mBAAA;Gf86DD;Ee/6DD;IACE,UAAA;Gfi7DD;Eel7DD;IACE,mBAAA;Gfo7DD;Eer7DD;IACE,mBAAA;Gfu7DD;Eex7DD;IACE,UAAA;Gf07DD;Ee37DD;IACE,mBAAA;Gf67DD;Ee97DD;IACE,kBAAA;Gfg8DD;Ee57DD;IACE,WAAA;Gf87DD;Eeh7DD;IACE,kBAAA;Gfk7DD;Een7DD;IACE,0BAAA;Gfq7DD;Eet7DD;IACE,0BAAA;Gfw7DD;Eez7DD;IACE,iBAAA;Gf27DD;Ee57DD;IACE,0BAAA;Gf87DD;Ee/7DD;IACE,0BAAA;Gfi8DD;Eel8DD;IACE,iBAAA;Gfo8DD;Eer8DD;IACE,0BAAA;Gfu8DD;Eex8DD;IACE,0BAAA;Gf08DD;Ee38DD;IACE,iBAAA;Gf68DD;Ee98DD;IACE,0BAAA;Gfg9DD;Eej9DD;IACE,yBAAA;Gfm9DD;Eep9DD;IACE,gBAAA;Gfs9DD;CACF;Aa38DD;EE9CI;IACE,YAAA;Gf4/DH;Eer/DD;IACE,YAAA;Gfu/DD;Eex/DD;IACE,oBAAA;Gf0/DD;Ee3/DD;IACE,oBAAA;Gf6/DD;Ee9/DD;IACE,WAAA;GfggED;EejgED;IACE,oBAAA;GfmgED;EepgED;IACE,oBAAA;GfsgED;EevgED;IACE,WAAA;GfygED;Ee1gED;IACE,oBAAA;Gf4gED;Ee7gED;IACE,oBAAA;Gf+gED;EehhED;IACE,WAAA;GfkhED;EenhED;IACE,oBAAA;GfqhED;EethED;IACE,mBAAA;GfwhED;Ee1gED;IACE,YAAA;Gf4gED;Ee7gED;IACE,oBAAA;Gf+gED;EehhED;IACE,oBAAA;GfkhED;EenhED;IACE,WAAA;GfqhED;EethED;IACE,oBAAA;GfwhED;EezhED;IACE,oBAAA;Gf2hED;Ee5hED;IACE,WAAA;Gf8hED;Ee/hED;IACE,oBAAA;GfiiED;EeliED;IACE,oBAAA;GfoiED;EeriED;IACE,WAAA;GfuiED;EexiED;IACE,oBAAA;Gf0iED;Ee3iED;IACE,mBAAA;Gf6iED;EeziED;IACE,YAAA;Gf2iED;Ee3jED;IACE,WAAA;Gf6jED;Ee9jED;IACE,mBAAA;GfgkED;EejkED;IACE,mBAAA;GfmkED;EepkED;IACE,UAAA;GfskED;EevkED;IACE,mBAAA;GfykED;Ee1kED;IACE,mBAAA;Gf4kED;Ee7kED;IACE,UAAA;Gf+kED;EehlED;IACE,mBAAA;GfklED;EenlED;IACE,mBAAA;GfqlED;EetlED;IACE,UAAA;GfwlED;EezlED;IACE,mBAAA;Gf2lED;Ee5lED;IACE,kBAAA;Gf8lED;Ee1lED;IACE,WAAA;Gf4lED;Ee9kED;IACE,kBAAA;GfglED;EejlED;IACE,0BAAA;GfmlED;EeplED;IACE,0BAAA;GfslED;EevlED;IACE,iBAAA;GfylED;Ee1lED;IACE,0BAAA;Gf4lED;Ee7lED;IACE,0BAAA;Gf+lED;EehmED;IACE,iBAAA;GfkmED;EenmED;IACE,0BAAA;GfqmED;EetmED;IACE,0BAAA;GfwmED;EezmED;IACE,iBAAA;Gf2mED;Ee5mED;IACE,0BAAA;Gf8mED;Ee/mED;IACE,yBAAA;GfinED;EelnED;IACE,gBAAA;GfonED;CACF;AgBxrED;EACE,8BAAA;ChB0rED;AgBxrED;EACE,iBAAA;EACA,oBAAA;EACA,eAAA;EACA,iBAAA;ChB0rED;AgBxrED;EACE,iBAAA;ChB0rED;AgBprED;EACE,YA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","file":"bootstrap.css","sourcesContent":["/*!\n * Bootstrap v3.3.6 (http://getbootstrap.com)\n * Copyright 2011-2015 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\nhtml {\n font-family: sans-serif;\n -ms-text-size-adjust: 100%;\n -webkit-text-size-adjust: 100%;\n}\nbody {\n margin: 0;\n}\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block;\n vertical-align: baseline;\n}\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n[hidden],\ntemplate {\n display: none;\n}\na {\n background-color: transparent;\n}\na:active,\na:hover {\n outline: 0;\n}\nabbr[title] {\n border-bottom: 1px dotted;\n}\nb,\nstrong {\n font-weight: bold;\n}\ndfn {\n font-style: italic;\n}\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\nmark {\n background: #ff0;\n color: #000;\n}\nsmall {\n font-size: 80%;\n}\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsup {\n top: -0.5em;\n}\nsub {\n bottom: -0.25em;\n}\nimg {\n border: 0;\n}\nsvg:not(:root) {\n overflow: hidden;\n}\nfigure {\n margin: 1em 40px;\n}\nhr {\n box-sizing: content-box;\n height: 0;\n}\npre {\n overflow: auto;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit;\n font: inherit;\n margin: 0;\n}\nbutton {\n overflow: visible;\n}\nbutton,\nselect {\n text-transform: none;\n}\nbutton,\nhtml input[type=\"button\"],\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button;\n cursor: pointer;\n}\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\ninput {\n line-height: normal;\n}\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box;\n padding: 0;\n}\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: textfield;\n box-sizing: content-box;\n}\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\nlegend {\n border: 0;\n padding: 0;\n}\ntextarea {\n overflow: auto;\n}\noptgroup {\n font-weight: bold;\n}\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\ntd,\nth {\n padding: 0;\n}\n/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important;\n box-shadow: none !important;\n text-shadow: none !important;\n }\n a,\n a:visited {\n text-decoration: underline;\n }\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n thead {\n display: table-header-group;\n }\n tr,\n img {\n page-break-inside: avoid;\n }\n img {\n max-width: 100% !important;\n }\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n h2,\n h3 {\n page-break-after: avoid;\n }\n .navbar {\n display: none;\n }\n .btn > .caret,\n .dropup > .btn > .caret {\n border-top-color: #000 !important;\n }\n .label {\n border: 1px solid #000;\n }\n .table {\n border-collapse: collapse !important;\n }\n .table td,\n .table th {\n background-color: #fff !important;\n }\n .table-bordered th,\n .table-bordered td {\n border: 1px solid #ddd !important;\n }\n}\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('../fonts/glyphicons-halflings-regular.eot');\n src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg');\n}\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n.glyphicon-asterisk:before {\n content: \"\\002a\";\n}\n.glyphicon-plus:before {\n content: \"\\002b\";\n}\n.glyphicon-euro:before,\n.glyphicon-eur:before {\n content: \"\\20ac\";\n}\n.glyphicon-minus:before {\n content: \"\\2212\";\n}\n.glyphicon-cloud:before {\n content: \"\\2601\";\n}\n.glyphicon-envelope:before {\n content: \"\\2709\";\n}\n.glyphicon-pencil:before {\n content: \"\\270f\";\n}\n.glyphicon-glass:before {\n content: \"\\e001\";\n}\n.glyphicon-music:before {\n content: \"\\e002\";\n}\n.glyphicon-search:before {\n content: \"\\e003\";\n}\n.glyphicon-heart:before {\n content: \"\\e005\";\n}\n.glyphicon-star:before {\n content: \"\\e006\";\n}\n.glyphicon-star-empty:before {\n content: \"\\e007\";\n}\n.glyphicon-user:before {\n content: \"\\e008\";\n}\n.glyphicon-film:before {\n content: \"\\e009\";\n}\n.glyphicon-th-large:before {\n content: \"\\e010\";\n}\n.glyphicon-th:before {\n content: \"\\e011\";\n}\n.glyphicon-th-list:before {\n content: \"\\e012\";\n}\n.glyphicon-ok:before {\n content: \"\\e013\";\n}\n.glyphicon-remove:before {\n content: \"\\e014\";\n}\n.glyphicon-zoom-in:before {\n content: \"\\e015\";\n}\n.glyphicon-zoom-out:before {\n content: \"\\e016\";\n}\n.glyphicon-off:before {\n content: \"\\e017\";\n}\n.glyphicon-signal:before {\n content: \"\\e018\";\n}\n.glyphicon-cog:before {\n content: \"\\e019\";\n}\n.glyphicon-trash:before {\n content: \"\\e020\";\n}\n.glyphicon-home:before {\n content: \"\\e021\";\n}\n.glyphicon-file:before {\n content: \"\\e022\";\n}\n.glyphicon-time:before {\n content: \"\\e023\";\n}\n.glyphicon-road:before {\n content: \"\\e024\";\n}\n.glyphicon-download-alt:before {\n content: \"\\e025\";\n}\n.glyphicon-download:before {\n content: \"\\e026\";\n}\n.glyphicon-upload:before {\n content: \"\\e027\";\n}\n.glyphicon-inbox:before {\n content: \"\\e028\";\n}\n.glyphicon-play-circle:before {\n content: \"\\e029\";\n}\n.glyphicon-repeat:before {\n content: \"\\e030\";\n}\n.glyphicon-refresh:before {\n content: \"\\e031\";\n}\n.glyphicon-list-alt:before {\n content: \"\\e032\";\n}\n.glyphicon-lock:before {\n content: \"\\e033\";\n}\n.glyphicon-flag:before {\n content: \"\\e034\";\n}\n.glyphicon-headphones:before {\n content: \"\\e035\";\n}\n.glyphicon-volume-off:before {\n content: \"\\e036\";\n}\n.glyphicon-volume-down:before {\n content: \"\\e037\";\n}\n.glyphicon-volume-up:before {\n content: \"\\e038\";\n}\n.glyphicon-qrcode:before {\n content: \"\\e039\";\n}\n.glyphicon-barcode:before {\n content: \"\\e040\";\n}\n.glyphicon-tag:before {\n content: \"\\e041\";\n}\n.glyphicon-tags:before {\n content: \"\\e042\";\n}\n.glyphicon-book:before {\n content: \"\\e043\";\n}\n.glyphicon-bookmark:before {\n content: \"\\e044\";\n}\n.glyphicon-print:before {\n content: \"\\e045\";\n}\n.glyphicon-camera:before {\n content: \"\\e046\";\n}\n.glyphicon-font:before {\n content: \"\\e047\";\n}\n.glyphicon-bold:before {\n content: \"\\e048\";\n}\n.glyphicon-italic:before {\n content: \"\\e049\";\n}\n.glyphicon-text-height:before {\n content: \"\\e050\";\n}\n.glyphicon-text-width:before {\n content: \"\\e051\";\n}\n.glyphicon-align-left:before {\n content: \"\\e052\";\n}\n.glyphicon-align-center:before {\n content: \"\\e053\";\n}\n.glyphicon-align-right:before {\n content: \"\\e054\";\n}\n.glyphicon-align-justify:before {\n content: \"\\e055\";\n}\n.glyphicon-list:before {\n content: \"\\e056\";\n}\n.glyphicon-indent-left:before {\n content: \"\\e057\";\n}\n.glyphicon-indent-right:before {\n content: \"\\e058\";\n}\n.glyphicon-facetime-video:before {\n content: \"\\e059\";\n}\n.glyphicon-picture:before {\n content: \"\\e060\";\n}\n.glyphicon-map-marker:before {\n content: \"\\e062\";\n}\n.glyphicon-adjust:before {\n content: \"\\e063\";\n}\n.glyphicon-tint:before {\n content: \"\\e064\";\n}\n.glyphicon-edit:before {\n content: \"\\e065\";\n}\n.glyphicon-share:before {\n content: \"\\e066\";\n}\n.glyphicon-check:before {\n content: \"\\e067\";\n}\n.glyphicon-move:before {\n content: \"\\e068\";\n}\n.glyphicon-step-backward:before {\n content: \"\\e069\";\n}\n.glyphicon-fast-backward:before {\n content: \"\\e070\";\n}\n.glyphicon-backward:before {\n content: \"\\e071\";\n}\n.glyphicon-play:before {\n content: \"\\e072\";\n}\n.glyphicon-pause:before {\n content: \"\\e073\";\n}\n.glyphicon-stop:before {\n content: \"\\e074\";\n}\n.glyphicon-forward:before {\n content: \"\\e075\";\n}\n.glyphicon-fast-forward:before {\n content: \"\\e076\";\n}\n.glyphicon-step-forward:before {\n content: \"\\e077\";\n}\n.glyphicon-eject:before {\n content: \"\\e078\";\n}\n.glyphicon-chevron-left:before {\n content: \"\\e079\";\n}\n.glyphicon-chevron-right:before {\n content: \"\\e080\";\n}\n.glyphicon-plus-sign:before {\n content: \"\\e081\";\n}\n.glyphicon-minus-sign:before {\n content: \"\\e082\";\n}\n.glyphicon-remove-sign:before {\n content: \"\\e083\";\n}\n.glyphicon-ok-sign:before {\n content: \"\\e084\";\n}\n.glyphicon-question-sign:before {\n content: \"\\e085\";\n}\n.glyphicon-info-sign:before {\n content: \"\\e086\";\n}\n.glyphicon-screenshot:before {\n content: \"\\e087\";\n}\n.glyphicon-remove-circle:before {\n content: \"\\e088\";\n}\n.glyphicon-ok-circle:before {\n content: \"\\e089\";\n}\n.glyphicon-ban-circle:before {\n content: \"\\e090\";\n}\n.glyphicon-arrow-left:before {\n content: \"\\e091\";\n}\n.glyphicon-arrow-right:before {\n content: \"\\e092\";\n}\n.glyphicon-arrow-up:before {\n content: \"\\e093\";\n}\n.glyphicon-arrow-down:before {\n content: \"\\e094\";\n}\n.glyphicon-share-alt:before {\n content: \"\\e095\";\n}\n.glyphicon-resize-full:before {\n content: \"\\e096\";\n}\n.glyphicon-resize-small:before {\n content: \"\\e097\";\n}\n.glyphicon-exclamation-sign:before {\n content: \"\\e101\";\n}\n.glyphicon-gift:before {\n content: \"\\e102\";\n}\n.glyphicon-leaf:before {\n content: \"\\e103\";\n}\n.glyphicon-fire:before {\n content: \"\\e104\";\n}\n.glyphicon-eye-open:before {\n content: \"\\e105\";\n}\n.glyphicon-eye-close:before {\n content: \"\\e106\";\n}\n.glyphicon-warning-sign:before {\n content: \"\\e107\";\n}\n.glyphicon-plane:before {\n content: \"\\e108\";\n}\n.glyphicon-calendar:before {\n content: \"\\e109\";\n}\n.glyphicon-random:before {\n content: \"\\e110\";\n}\n.glyphicon-comment:before {\n content: \"\\e111\";\n}\n.glyphicon-magnet:before {\n content: \"\\e112\";\n}\n.glyphicon-chevron-up:before {\n content: \"\\e113\";\n}\n.glyphicon-chevron-down:before {\n content: \"\\e114\";\n}\n.glyphicon-retweet:before {\n content: \"\\e115\";\n}\n.glyphicon-shopping-cart:before {\n content: \"\\e116\";\n}\n.glyphicon-folder-close:before {\n content: \"\\e117\";\n}\n.glyphicon-folder-open:before {\n content: \"\\e118\";\n}\n.glyphicon-resize-vertical:before {\n content: \"\\e119\";\n}\n.glyphicon-resize-horizontal:before {\n content: \"\\e120\";\n}\n.glyphicon-hdd:before {\n content: \"\\e121\";\n}\n.glyphicon-bullhorn:before {\n content: \"\\e122\";\n}\n.glyphicon-bell:before {\n content: \"\\e123\";\n}\n.glyphicon-certificate:before {\n content: \"\\e124\";\n}\n.glyphicon-thumbs-up:before {\n content: \"\\e125\";\n}\n.glyphicon-thumbs-down:before {\n content: \"\\e126\";\n}\n.glyphicon-hand-right:before {\n content: \"\\e127\";\n}\n.glyphicon-hand-left:before {\n content: \"\\e128\";\n}\n.glyphicon-hand-up:before {\n content: \"\\e129\";\n}\n.glyphicon-hand-down:before {\n content: \"\\e130\";\n}\n.glyphicon-circle-arrow-right:before {\n content: \"\\e131\";\n}\n.glyphicon-circle-arrow-left:before {\n content: \"\\e132\";\n}\n.glyphicon-circle-arrow-up:before {\n content: \"\\e133\";\n}\n.glyphicon-circle-arrow-down:before {\n content: \"\\e134\";\n}\n.glyphicon-globe:before {\n content: \"\\e135\";\n}\n.glyphicon-wrench:before {\n content: \"\\e136\";\n}\n.glyphicon-tasks:before {\n content: \"\\e137\";\n}\n.glyphicon-filter:before {\n content: \"\\e138\";\n}\n.glyphicon-briefcase:before {\n content: \"\\e139\";\n}\n.glyphicon-fullscreen:before {\n content: \"\\e140\";\n}\n.glyphicon-dashboard:before {\n content: \"\\e141\";\n}\n.glyphicon-paperclip:before {\n content: \"\\e142\";\n}\n.glyphicon-heart-empty:before {\n content: \"\\e143\";\n}\n.glyphicon-link:before {\n content: \"\\e144\";\n}\n.glyphicon-phone:before {\n content: \"\\e145\";\n}\n.glyphicon-pushpin:before {\n content: \"\\e146\";\n}\n.glyphicon-usd:before {\n content: \"\\e148\";\n}\n.glyphicon-gbp:before {\n content: \"\\e149\";\n}\n.glyphicon-sort:before {\n content: \"\\e150\";\n}\n.glyphicon-sort-by-alphabet:before {\n content: \"\\e151\";\n}\n.glyphicon-sort-by-alphabet-alt:before {\n content: \"\\e152\";\n}\n.glyphicon-sort-by-order:before {\n content: \"\\e153\";\n}\n.glyphicon-sort-by-order-alt:before {\n content: \"\\e154\";\n}\n.glyphicon-sort-by-attributes:before {\n content: \"\\e155\";\n}\n.glyphicon-sort-by-attributes-alt:before {\n content: \"\\e156\";\n}\n.glyphicon-unchecked:before {\n content: \"\\e157\";\n}\n.glyphicon-expand:before {\n content: \"\\e158\";\n}\n.glyphicon-collapse-down:before {\n content: \"\\e159\";\n}\n.glyphicon-collapse-up:before {\n content: \"\\e160\";\n}\n.glyphicon-log-in:before {\n content: \"\\e161\";\n}\n.glyphicon-flash:before {\n content: \"\\e162\";\n}\n.glyphicon-log-out:before {\n content: \"\\e163\";\n}\n.glyphicon-new-window:before {\n content: \"\\e164\";\n}\n.glyphicon-record:before {\n content: \"\\e165\";\n}\n.glyphicon-save:before {\n content: \"\\e166\";\n}\n.glyphicon-open:before {\n content: \"\\e167\";\n}\n.glyphicon-saved:before {\n content: \"\\e168\";\n}\n.glyphicon-import:before {\n content: \"\\e169\";\n}\n.glyphicon-export:before {\n content: \"\\e170\";\n}\n.glyphicon-send:before {\n content: \"\\e171\";\n}\n.glyphicon-floppy-disk:before {\n content: \"\\e172\";\n}\n.glyphicon-floppy-saved:before {\n content: \"\\e173\";\n}\n.glyphicon-floppy-remove:before {\n content: \"\\e174\";\n}\n.glyphicon-floppy-save:before {\n content: \"\\e175\";\n}\n.glyphicon-floppy-open:before {\n content: \"\\e176\";\n}\n.glyphicon-credit-card:before {\n content: \"\\e177\";\n}\n.glyphicon-transfer:before {\n content: \"\\e178\";\n}\n.glyphicon-cutlery:before {\n content: \"\\e179\";\n}\n.glyphicon-header:before {\n content: \"\\e180\";\n}\n.glyphicon-compressed:before {\n content: \"\\e181\";\n}\n.glyphicon-earphone:before {\n content: \"\\e182\";\n}\n.glyphicon-phone-alt:before {\n content: \"\\e183\";\n}\n.glyphicon-tower:before {\n content: \"\\e184\";\n}\n.glyphicon-stats:before {\n content: \"\\e185\";\n}\n.glyphicon-sd-video:before {\n content: \"\\e186\";\n}\n.glyphicon-hd-video:before {\n content: \"\\e187\";\n}\n.glyphicon-subtitles:before {\n content: \"\\e188\";\n}\n.glyphicon-sound-stereo:before {\n content: \"\\e189\";\n}\n.glyphicon-sound-dolby:before {\n content: \"\\e190\";\n}\n.glyphicon-sound-5-1:before {\n content: \"\\e191\";\n}\n.glyphicon-sound-6-1:before {\n content: \"\\e192\";\n}\n.glyphicon-sound-7-1:before {\n content: \"\\e193\";\n}\n.glyphicon-copyright-mark:before {\n content: \"\\e194\";\n}\n.glyphicon-registration-mark:before {\n content: \"\\e195\";\n}\n.glyphicon-cloud-download:before {\n content: \"\\e197\";\n}\n.glyphicon-cloud-upload:before {\n content: \"\\e198\";\n}\n.glyphicon-tree-conifer:before {\n content: \"\\e199\";\n}\n.glyphicon-tree-deciduous:before {\n content: \"\\e200\";\n}\n.glyphicon-cd:before {\n content: \"\\e201\";\n}\n.glyphicon-save-file:before {\n content: \"\\e202\";\n}\n.glyphicon-open-file:before {\n content: \"\\e203\";\n}\n.glyphicon-level-up:before {\n content: \"\\e204\";\n}\n.glyphicon-copy:before {\n content: \"\\e205\";\n}\n.glyphicon-paste:before {\n content: \"\\e206\";\n}\n.glyphicon-alert:before {\n content: \"\\e209\";\n}\n.glyphicon-equalizer:before {\n content: \"\\e210\";\n}\n.glyphicon-king:before {\n content: \"\\e211\";\n}\n.glyphicon-queen:before {\n content: \"\\e212\";\n}\n.glyphicon-pawn:before {\n content: \"\\e213\";\n}\n.glyphicon-bishop:before {\n content: \"\\e214\";\n}\n.glyphicon-knight:before {\n content: \"\\e215\";\n}\n.glyphicon-baby-formula:before {\n content: \"\\e216\";\n}\n.glyphicon-tent:before {\n content: \"\\26fa\";\n}\n.glyphicon-blackboard:before {\n content: \"\\e218\";\n}\n.glyphicon-bed:before {\n content: \"\\e219\";\n}\n.glyphicon-apple:before {\n content: \"\\f8ff\";\n}\n.glyphicon-erase:before {\n content: \"\\e221\";\n}\n.glyphicon-hourglass:before {\n content: \"\\231b\";\n}\n.glyphicon-lamp:before {\n content: \"\\e223\";\n}\n.glyphicon-duplicate:before {\n content: \"\\e224\";\n}\n.glyphicon-piggy-bank:before {\n content: \"\\e225\";\n}\n.glyphicon-scissors:before {\n content: \"\\e226\";\n}\n.glyphicon-bitcoin:before {\n content: \"\\e227\";\n}\n.glyphicon-btc:before {\n content: \"\\e227\";\n}\n.glyphicon-xbt:before {\n content: \"\\e227\";\n}\n.glyphicon-yen:before {\n content: \"\\00a5\";\n}\n.glyphicon-jpy:before {\n content: \"\\00a5\";\n}\n.glyphicon-ruble:before {\n content: \"\\20bd\";\n}\n.glyphicon-rub:before {\n content: \"\\20bd\";\n}\n.glyphicon-scale:before {\n content: \"\\e230\";\n}\n.glyphicon-ice-lolly:before {\n content: \"\\e231\";\n}\n.glyphicon-ice-lolly-tasted:before {\n content: \"\\e232\";\n}\n.glyphicon-education:before {\n content: \"\\e233\";\n}\n.glyphicon-option-horizontal:before {\n content: \"\\e234\";\n}\n.glyphicon-option-vertical:before {\n content: \"\\e235\";\n}\n.glyphicon-menu-hamburger:before {\n content: \"\\e236\";\n}\n.glyphicon-modal-window:before {\n content: \"\\e237\";\n}\n.glyphicon-oil:before {\n content: \"\\e238\";\n}\n.glyphicon-grain:before {\n content: \"\\e239\";\n}\n.glyphicon-sunglasses:before {\n content: \"\\e240\";\n}\n.glyphicon-text-size:before {\n content: \"\\e241\";\n}\n.glyphicon-text-color:before {\n content: \"\\e242\";\n}\n.glyphicon-text-background:before {\n content: \"\\e243\";\n}\n.glyphicon-object-align-top:before {\n content: \"\\e244\";\n}\n.glyphicon-object-align-bottom:before {\n content: \"\\e245\";\n}\n.glyphicon-object-align-horizontal:before {\n content: \"\\e246\";\n}\n.glyphicon-object-align-left:before {\n content: \"\\e247\";\n}\n.glyphicon-object-align-vertical:before {\n content: \"\\e248\";\n}\n.glyphicon-object-align-right:before {\n content: \"\\e249\";\n}\n.glyphicon-triangle-right:before {\n content: \"\\e250\";\n}\n.glyphicon-triangle-left:before {\n content: \"\\e251\";\n}\n.glyphicon-triangle-bottom:before {\n content: \"\\e252\";\n}\n.glyphicon-triangle-top:before {\n content: \"\\e253\";\n}\n.glyphicon-console:before {\n content: \"\\e254\";\n}\n.glyphicon-superscript:before {\n content: \"\\e255\";\n}\n.glyphicon-subscript:before {\n content: \"\\e256\";\n}\n.glyphicon-menu-left:before {\n content: \"\\e257\";\n}\n.glyphicon-menu-right:before {\n content: \"\\e258\";\n}\n.glyphicon-menu-down:before {\n content: \"\\e259\";\n}\n.glyphicon-menu-up:before {\n content: \"\\e260\";\n}\n* {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\n*:before,\n*:after {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\nbody {\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 14px;\n line-height: 1.42857143;\n color: #333333;\n background-color: #fff;\n}\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\na {\n color: #337ab7;\n text-decoration: none;\n}\na:hover,\na:focus {\n color: #23527c;\n text-decoration: underline;\n}\na:focus {\n outline: thin dotted;\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\nfigure {\n margin: 0;\n}\nimg {\n vertical-align: middle;\n}\n.img-responsive,\n.thumbnail > img,\n.thumbnail a > img,\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n display: block;\n max-width: 100%;\n height: auto;\n}\n.img-rounded {\n border-radius: 6px;\n}\n.img-thumbnail {\n padding: 4px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: all 0.2s ease-in-out;\n -o-transition: all 0.2s ease-in-out;\n transition: all 0.2s ease-in-out;\n display: inline-block;\n max-width: 100%;\n height: auto;\n}\n.img-circle {\n border-radius: 50%;\n}\nhr {\n margin-top: 20px;\n margin-bottom: 20px;\n border: 0;\n border-top: 1px solid #eeeeee;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n border: 0;\n}\n.sr-only-focusable:active,\n.sr-only-focusable:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n}\n[role=\"button\"] {\n cursor: pointer;\n}\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\n.h1,\n.h2,\n.h3,\n.h4,\n.h5,\n.h6 {\n font-family: inherit;\n font-weight: 500;\n line-height: 1.1;\n color: inherit;\n}\nh1 small,\nh2 small,\nh3 small,\nh4 small,\nh5 small,\nh6 small,\n.h1 small,\n.h2 small,\n.h3 small,\n.h4 small,\n.h5 small,\n.h6 small,\nh1 .small,\nh2 .small,\nh3 .small,\nh4 .small,\nh5 .small,\nh6 .small,\n.h1 .small,\n.h2 .small,\n.h3 .small,\n.h4 .small,\n.h5 .small,\n.h6 .small {\n font-weight: normal;\n line-height: 1;\n color: #777777;\n}\nh1,\n.h1,\nh2,\n.h2,\nh3,\n.h3 {\n margin-top: 20px;\n margin-bottom: 10px;\n}\nh1 small,\n.h1 small,\nh2 small,\n.h2 small,\nh3 small,\n.h3 small,\nh1 .small,\n.h1 .small,\nh2 .small,\n.h2 .small,\nh3 .small,\n.h3 .small {\n font-size: 65%;\n}\nh4,\n.h4,\nh5,\n.h5,\nh6,\n.h6 {\n margin-top: 10px;\n margin-bottom: 10px;\n}\nh4 small,\n.h4 small,\nh5 small,\n.h5 small,\nh6 small,\n.h6 small,\nh4 .small,\n.h4 .small,\nh5 .small,\n.h5 .small,\nh6 .small,\n.h6 .small {\n font-size: 75%;\n}\nh1,\n.h1 {\n font-size: 36px;\n}\nh2,\n.h2 {\n font-size: 30px;\n}\nh3,\n.h3 {\n font-size: 24px;\n}\nh4,\n.h4 {\n font-size: 18px;\n}\nh5,\n.h5 {\n font-size: 14px;\n}\nh6,\n.h6 {\n font-size: 12px;\n}\np {\n margin: 0 0 10px;\n}\n.lead {\n margin-bottom: 20px;\n font-size: 16px;\n font-weight: 300;\n line-height: 1.4;\n}\n@media (min-width: 768px) {\n .lead {\n font-size: 21px;\n }\n}\nsmall,\n.small {\n font-size: 85%;\n}\nmark,\n.mark {\n background-color: #fcf8e3;\n padding: .2em;\n}\n.text-left {\n text-align: left;\n}\n.text-right {\n text-align: right;\n}\n.text-center {\n text-align: center;\n}\n.text-justify {\n text-align: justify;\n}\n.text-nowrap {\n white-space: nowrap;\n}\n.text-lowercase {\n text-transform: lowercase;\n}\n.text-uppercase {\n text-transform: uppercase;\n}\n.text-capitalize {\n text-transform: capitalize;\n}\n.text-muted {\n color: #777777;\n}\n.text-primary {\n color: #337ab7;\n}\na.text-primary:hover,\na.text-primary:focus {\n color: #286090;\n}\n.text-success {\n color: #3c763d;\n}\na.text-success:hover,\na.text-success:focus {\n color: #2b542c;\n}\n.text-info {\n color: #31708f;\n}\na.text-info:hover,\na.text-info:focus {\n color: #245269;\n}\n.text-warning {\n color: #8a6d3b;\n}\na.text-warning:hover,\na.text-warning:focus {\n color: #66512c;\n}\n.text-danger {\n color: #a94442;\n}\na.text-danger:hover,\na.text-danger:focus {\n color: #843534;\n}\n.bg-primary {\n color: #fff;\n background-color: #337ab7;\n}\na.bg-primary:hover,\na.bg-primary:focus {\n background-color: #286090;\n}\n.bg-success {\n background-color: #dff0d8;\n}\na.bg-success:hover,\na.bg-success:focus {\n background-color: #c1e2b3;\n}\n.bg-info {\n background-color: #d9edf7;\n}\na.bg-info:hover,\na.bg-info:focus {\n background-color: #afd9ee;\n}\n.bg-warning {\n background-color: #fcf8e3;\n}\na.bg-warning:hover,\na.bg-warning:focus {\n background-color: #f7ecb5;\n}\n.bg-danger {\n background-color: #f2dede;\n}\na.bg-danger:hover,\na.bg-danger:focus {\n background-color: #e4b9b9;\n}\n.page-header {\n padding-bottom: 9px;\n margin: 40px 0 20px;\n border-bottom: 1px solid #eeeeee;\n}\nul,\nol {\n margin-top: 0;\n margin-bottom: 10px;\n}\nul ul,\nol ul,\nul ol,\nol ol {\n margin-bottom: 0;\n}\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n.list-inline {\n padding-left: 0;\n list-style: none;\n margin-left: -5px;\n}\n.list-inline > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n}\ndl {\n margin-top: 0;\n margin-bottom: 20px;\n}\ndt,\ndd {\n line-height: 1.42857143;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0;\n}\n@media (min-width: 768px) {\n .dl-horizontal dt {\n float: left;\n width: 160px;\n clear: left;\n text-align: right;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n .dl-horizontal dd {\n margin-left: 180px;\n }\n}\nabbr[title],\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted #777777;\n}\n.initialism {\n font-size: 90%;\n text-transform: uppercase;\n}\nblockquote {\n padding: 10px 20px;\n margin: 0 0 20px;\n font-size: 17.5px;\n border-left: 5px solid #eeeeee;\n}\nblockquote p:last-child,\nblockquote ul:last-child,\nblockquote ol:last-child {\n margin-bottom: 0;\n}\nblockquote footer,\nblockquote small,\nblockquote .small {\n display: block;\n font-size: 80%;\n line-height: 1.42857143;\n color: #777777;\n}\nblockquote footer:before,\nblockquote small:before,\nblockquote .small:before {\n content: '\\2014 \\00A0';\n}\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid #eeeeee;\n border-left: 0;\n text-align: right;\n}\n.blockquote-reverse footer:before,\nblockquote.pull-right footer:before,\n.blockquote-reverse small:before,\nblockquote.pull-right small:before,\n.blockquote-reverse .small:before,\nblockquote.pull-right .small:before {\n content: '';\n}\n.blockquote-reverse footer:after,\nblockquote.pull-right footer:after,\n.blockquote-reverse small:after,\nblockquote.pull-right small:after,\n.blockquote-reverse .small:after,\nblockquote.pull-right .small:after {\n content: '\\00A0 \\2014';\n}\naddress {\n margin-bottom: 20px;\n font-style: normal;\n line-height: 1.42857143;\n}\ncode,\nkbd,\npre,\nsamp {\n font-family: Menlo, Monaco, Consolas, \"Courier New\", monospace;\n}\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: #c7254e;\n background-color: #f9f2f4;\n border-radius: 4px;\n}\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: #fff;\n background-color: #333;\n border-radius: 3px;\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);\n}\nkbd kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n}\npre {\n display: block;\n padding: 9.5px;\n margin: 0 0 10px;\n font-size: 13px;\n line-height: 1.42857143;\n word-break: break-all;\n word-wrap: break-word;\n color: #333333;\n background-color: #f5f5f5;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\npre code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n}\n.pre-scrollable {\n max-height: 340px;\n overflow-y: scroll;\n}\n.container {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n@media (min-width: 768px) {\n .container {\n width: 750px;\n }\n}\n@media (min-width: 992px) {\n .container {\n width: 970px;\n }\n}\n@media (min-width: 1200px) {\n .container {\n width: 1170px;\n }\n}\n.container-fluid {\n margin-right: auto;\n margin-left: auto;\n padding-left: 15px;\n padding-right: 15px;\n}\n.row {\n margin-left: -15px;\n margin-right: -15px;\n}\n.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 {\n position: relative;\n min-height: 1px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 {\n float: left;\n}\n.col-xs-12 {\n width: 100%;\n}\n.col-xs-11 {\n width: 91.66666667%;\n}\n.col-xs-10 {\n width: 83.33333333%;\n}\n.col-xs-9 {\n width: 75%;\n}\n.col-xs-8 {\n width: 66.66666667%;\n}\n.col-xs-7 {\n width: 58.33333333%;\n}\n.col-xs-6 {\n width: 50%;\n}\n.col-xs-5 {\n width: 41.66666667%;\n}\n.col-xs-4 {\n width: 33.33333333%;\n}\n.col-xs-3 {\n width: 25%;\n}\n.col-xs-2 {\n width: 16.66666667%;\n}\n.col-xs-1 {\n width: 8.33333333%;\n}\n.col-xs-pull-12 {\n right: 100%;\n}\n.col-xs-pull-11 {\n right: 91.66666667%;\n}\n.col-xs-pull-10 {\n right: 83.33333333%;\n}\n.col-xs-pull-9 {\n right: 75%;\n}\n.col-xs-pull-8 {\n right: 66.66666667%;\n}\n.col-xs-pull-7 {\n right: 58.33333333%;\n}\n.col-xs-pull-6 {\n right: 50%;\n}\n.col-xs-pull-5 {\n right: 41.66666667%;\n}\n.col-xs-pull-4 {\n right: 33.33333333%;\n}\n.col-xs-pull-3 {\n right: 25%;\n}\n.col-xs-pull-2 {\n right: 16.66666667%;\n}\n.col-xs-pull-1 {\n right: 8.33333333%;\n}\n.col-xs-pull-0 {\n right: auto;\n}\n.col-xs-push-12 {\n left: 100%;\n}\n.col-xs-push-11 {\n left: 91.66666667%;\n}\n.col-xs-push-10 {\n left: 83.33333333%;\n}\n.col-xs-push-9 {\n left: 75%;\n}\n.col-xs-push-8 {\n left: 66.66666667%;\n}\n.col-xs-push-7 {\n left: 58.33333333%;\n}\n.col-xs-push-6 {\n left: 50%;\n}\n.col-xs-push-5 {\n left: 41.66666667%;\n}\n.col-xs-push-4 {\n left: 33.33333333%;\n}\n.col-xs-push-3 {\n left: 25%;\n}\n.col-xs-push-2 {\n left: 16.66666667%;\n}\n.col-xs-push-1 {\n left: 8.33333333%;\n}\n.col-xs-push-0 {\n left: auto;\n}\n.col-xs-offset-12 {\n margin-left: 100%;\n}\n.col-xs-offset-11 {\n margin-left: 91.66666667%;\n}\n.col-xs-offset-10 {\n margin-left: 83.33333333%;\n}\n.col-xs-offset-9 {\n margin-left: 75%;\n}\n.col-xs-offset-8 {\n margin-left: 66.66666667%;\n}\n.col-xs-offset-7 {\n margin-left: 58.33333333%;\n}\n.col-xs-offset-6 {\n margin-left: 50%;\n}\n.col-xs-offset-5 {\n margin-left: 41.66666667%;\n}\n.col-xs-offset-4 {\n margin-left: 33.33333333%;\n}\n.col-xs-offset-3 {\n margin-left: 25%;\n}\n.col-xs-offset-2 {\n margin-left: 16.66666667%;\n}\n.col-xs-offset-1 {\n margin-left: 8.33333333%;\n}\n.col-xs-offset-0 {\n margin-left: 0%;\n}\n@media (min-width: 768px) {\n .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 {\n float: left;\n }\n .col-sm-12 {\n width: 100%;\n }\n .col-sm-11 {\n width: 91.66666667%;\n }\n .col-sm-10 {\n width: 83.33333333%;\n }\n .col-sm-9 {\n width: 75%;\n }\n .col-sm-8 {\n width: 66.66666667%;\n }\n .col-sm-7 {\n width: 58.33333333%;\n }\n .col-sm-6 {\n width: 50%;\n }\n .col-sm-5 {\n width: 41.66666667%;\n }\n .col-sm-4 {\n width: 33.33333333%;\n }\n .col-sm-3 {\n width: 25%;\n }\n .col-sm-2 {\n width: 16.66666667%;\n }\n .col-sm-1 {\n width: 8.33333333%;\n }\n .col-sm-pull-12 {\n right: 100%;\n }\n .col-sm-pull-11 {\n right: 91.66666667%;\n }\n .col-sm-pull-10 {\n right: 83.33333333%;\n }\n .col-sm-pull-9 {\n right: 75%;\n }\n .col-sm-pull-8 {\n right: 66.66666667%;\n }\n .col-sm-pull-7 {\n right: 58.33333333%;\n }\n .col-sm-pull-6 {\n right: 50%;\n }\n .col-sm-pull-5 {\n right: 41.66666667%;\n }\n .col-sm-pull-4 {\n right: 33.33333333%;\n }\n .col-sm-pull-3 {\n right: 25%;\n }\n .col-sm-pull-2 {\n right: 16.66666667%;\n }\n .col-sm-pull-1 {\n right: 8.33333333%;\n }\n .col-sm-pull-0 {\n right: auto;\n }\n .col-sm-push-12 {\n left: 100%;\n }\n .col-sm-push-11 {\n left: 91.66666667%;\n }\n .col-sm-push-10 {\n left: 83.33333333%;\n }\n .col-sm-push-9 {\n left: 75%;\n }\n .col-sm-push-8 {\n left: 66.66666667%;\n }\n .col-sm-push-7 {\n left: 58.33333333%;\n }\n .col-sm-push-6 {\n left: 50%;\n }\n .col-sm-push-5 {\n left: 41.66666667%;\n }\n .col-sm-push-4 {\n left: 33.33333333%;\n }\n .col-sm-push-3 {\n left: 25%;\n }\n .col-sm-push-2 {\n left: 16.66666667%;\n }\n .col-sm-push-1 {\n left: 8.33333333%;\n }\n .col-sm-push-0 {\n left: auto;\n }\n .col-sm-offset-12 {\n margin-left: 100%;\n }\n .col-sm-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-sm-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-sm-offset-9 {\n margin-left: 75%;\n }\n .col-sm-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-sm-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-sm-offset-6 {\n margin-left: 50%;\n }\n .col-sm-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-sm-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-sm-offset-3 {\n margin-left: 25%;\n }\n .col-sm-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-sm-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-sm-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 992px) {\n .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 {\n float: left;\n }\n .col-md-12 {\n width: 100%;\n }\n .col-md-11 {\n width: 91.66666667%;\n }\n .col-md-10 {\n width: 83.33333333%;\n }\n .col-md-9 {\n width: 75%;\n }\n .col-md-8 {\n width: 66.66666667%;\n }\n .col-md-7 {\n width: 58.33333333%;\n }\n .col-md-6 {\n width: 50%;\n }\n .col-md-5 {\n width: 41.66666667%;\n }\n .col-md-4 {\n width: 33.33333333%;\n }\n .col-md-3 {\n width: 25%;\n }\n .col-md-2 {\n width: 16.66666667%;\n }\n .col-md-1 {\n width: 8.33333333%;\n }\n .col-md-pull-12 {\n right: 100%;\n }\n .col-md-pull-11 {\n right: 91.66666667%;\n }\n .col-md-pull-10 {\n right: 83.33333333%;\n }\n .col-md-pull-9 {\n right: 75%;\n }\n .col-md-pull-8 {\n right: 66.66666667%;\n }\n .col-md-pull-7 {\n right: 58.33333333%;\n }\n .col-md-pull-6 {\n right: 50%;\n }\n .col-md-pull-5 {\n right: 41.66666667%;\n }\n .col-md-pull-4 {\n right: 33.33333333%;\n }\n .col-md-pull-3 {\n right: 25%;\n }\n .col-md-pull-2 {\n right: 16.66666667%;\n }\n .col-md-pull-1 {\n right: 8.33333333%;\n }\n .col-md-pull-0 {\n right: auto;\n }\n .col-md-push-12 {\n left: 100%;\n }\n .col-md-push-11 {\n left: 91.66666667%;\n }\n .col-md-push-10 {\n left: 83.33333333%;\n }\n .col-md-push-9 {\n left: 75%;\n }\n .col-md-push-8 {\n left: 66.66666667%;\n }\n .col-md-push-7 {\n left: 58.33333333%;\n }\n .col-md-push-6 {\n left: 50%;\n }\n .col-md-push-5 {\n left: 41.66666667%;\n }\n .col-md-push-4 {\n left: 33.33333333%;\n }\n .col-md-push-3 {\n left: 25%;\n }\n .col-md-push-2 {\n left: 16.66666667%;\n }\n .col-md-push-1 {\n left: 8.33333333%;\n }\n .col-md-push-0 {\n left: auto;\n }\n .col-md-offset-12 {\n margin-left: 100%;\n }\n .col-md-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-md-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-md-offset-9 {\n margin-left: 75%;\n }\n .col-md-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-md-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-md-offset-6 {\n margin-left: 50%;\n }\n .col-md-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-md-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-md-offset-3 {\n margin-left: 25%;\n }\n .col-md-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-md-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-md-offset-0 {\n margin-left: 0%;\n }\n}\n@media (min-width: 1200px) {\n .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 {\n float: left;\n }\n .col-lg-12 {\n width: 100%;\n }\n .col-lg-11 {\n width: 91.66666667%;\n }\n .col-lg-10 {\n width: 83.33333333%;\n }\n .col-lg-9 {\n width: 75%;\n }\n .col-lg-8 {\n width: 66.66666667%;\n }\n .col-lg-7 {\n width: 58.33333333%;\n }\n .col-lg-6 {\n width: 50%;\n }\n .col-lg-5 {\n width: 41.66666667%;\n }\n .col-lg-4 {\n width: 33.33333333%;\n }\n .col-lg-3 {\n width: 25%;\n }\n .col-lg-2 {\n width: 16.66666667%;\n }\n .col-lg-1 {\n width: 8.33333333%;\n }\n .col-lg-pull-12 {\n right: 100%;\n }\n .col-lg-pull-11 {\n right: 91.66666667%;\n }\n .col-lg-pull-10 {\n right: 83.33333333%;\n }\n .col-lg-pull-9 {\n right: 75%;\n }\n .col-lg-pull-8 {\n right: 66.66666667%;\n }\n .col-lg-pull-7 {\n right: 58.33333333%;\n }\n .col-lg-pull-6 {\n right: 50%;\n }\n .col-lg-pull-5 {\n right: 41.66666667%;\n }\n .col-lg-pull-4 {\n right: 33.33333333%;\n }\n .col-lg-pull-3 {\n right: 25%;\n }\n .col-lg-pull-2 {\n right: 16.66666667%;\n }\n .col-lg-pull-1 {\n right: 8.33333333%;\n }\n .col-lg-pull-0 {\n right: auto;\n }\n .col-lg-push-12 {\n left: 100%;\n }\n .col-lg-push-11 {\n left: 91.66666667%;\n }\n .col-lg-push-10 {\n left: 83.33333333%;\n }\n .col-lg-push-9 {\n left: 75%;\n }\n .col-lg-push-8 {\n left: 66.66666667%;\n }\n .col-lg-push-7 {\n left: 58.33333333%;\n }\n .col-lg-push-6 {\n left: 50%;\n }\n .col-lg-push-5 {\n left: 41.66666667%;\n }\n .col-lg-push-4 {\n left: 33.33333333%;\n }\n .col-lg-push-3 {\n left: 25%;\n }\n .col-lg-push-2 {\n left: 16.66666667%;\n }\n .col-lg-push-1 {\n left: 8.33333333%;\n }\n .col-lg-push-0 {\n left: auto;\n }\n .col-lg-offset-12 {\n margin-left: 100%;\n }\n .col-lg-offset-11 {\n margin-left: 91.66666667%;\n }\n .col-lg-offset-10 {\n margin-left: 83.33333333%;\n }\n .col-lg-offset-9 {\n margin-left: 75%;\n }\n .col-lg-offset-8 {\n margin-left: 66.66666667%;\n }\n .col-lg-offset-7 {\n margin-left: 58.33333333%;\n }\n .col-lg-offset-6 {\n margin-left: 50%;\n }\n .col-lg-offset-5 {\n margin-left: 41.66666667%;\n }\n .col-lg-offset-4 {\n margin-left: 33.33333333%;\n }\n .col-lg-offset-3 {\n margin-left: 25%;\n }\n .col-lg-offset-2 {\n margin-left: 16.66666667%;\n }\n .col-lg-offset-1 {\n margin-left: 8.33333333%;\n }\n .col-lg-offset-0 {\n margin-left: 0%;\n }\n}\ntable {\n background-color: transparent;\n}\ncaption {\n padding-top: 8px;\n padding-bottom: 8px;\n color: #777777;\n text-align: left;\n}\nth {\n text-align: left;\n}\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: 20px;\n}\n.table > thead > tr > th,\n.table > tbody > tr > th,\n.table > tfoot > tr > th,\n.table > thead > tr > td,\n.table > tbody > tr > td,\n.table > tfoot > tr > td {\n padding: 8px;\n line-height: 1.42857143;\n vertical-align: top;\n border-top: 1px solid #ddd;\n}\n.table > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid #ddd;\n}\n.table > caption + thead > tr:first-child > th,\n.table > colgroup + thead > tr:first-child > th,\n.table > thead:first-child > tr:first-child > th,\n.table > caption + thead > tr:first-child > td,\n.table > colgroup + thead > tr:first-child > td,\n.table > thead:first-child > tr:first-child > td {\n border-top: 0;\n}\n.table > tbody + tbody {\n border-top: 2px solid #ddd;\n}\n.table .table {\n background-color: #fff;\n}\n.table-condensed > thead > tr > th,\n.table-condensed > tbody > tr > th,\n.table-condensed > tfoot > tr > th,\n.table-condensed > thead > tr > td,\n.table-condensed > tbody > tr > td,\n.table-condensed > tfoot > tr > td {\n padding: 5px;\n}\n.table-bordered {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > tbody > tr > th,\n.table-bordered > tfoot > tr > th,\n.table-bordered > thead > tr > td,\n.table-bordered > tbody > tr > td,\n.table-bordered > tfoot > tr > td {\n border: 1px solid #ddd;\n}\n.table-bordered > thead > tr > th,\n.table-bordered > thead > tr > td {\n border-bottom-width: 2px;\n}\n.table-striped > tbody > tr:nth-of-type(odd) {\n background-color: #f9f9f9;\n}\n.table-hover > tbody > tr:hover {\n background-color: #f5f5f5;\n}\ntable col[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-column;\n}\ntable td[class*=\"col-\"],\ntable th[class*=\"col-\"] {\n position: static;\n float: none;\n display: table-cell;\n}\n.table > thead > tr > td.active,\n.table > tbody > tr > td.active,\n.table > tfoot > tr > td.active,\n.table > thead > tr > th.active,\n.table > tbody > tr > th.active,\n.table > tfoot > tr > th.active,\n.table > thead > tr.active > td,\n.table > tbody > tr.active > td,\n.table > tfoot > tr.active > td,\n.table > thead > tr.active > th,\n.table > tbody > tr.active > th,\n.table > tfoot > tr.active > th {\n background-color: #f5f5f5;\n}\n.table-hover > tbody > tr > td.active:hover,\n.table-hover > tbody > tr > th.active:hover,\n.table-hover > tbody > tr.active:hover > td,\n.table-hover > tbody > tr:hover > .active,\n.table-hover > tbody > tr.active:hover > th {\n background-color: #e8e8e8;\n}\n.table > thead > tr > td.success,\n.table > tbody > tr > td.success,\n.table > tfoot > tr > td.success,\n.table > thead > tr > th.success,\n.table > tbody > tr > th.success,\n.table > tfoot > tr > th.success,\n.table > thead > tr.success > td,\n.table > tbody > tr.success > td,\n.table > tfoot > tr.success > td,\n.table > thead > tr.success > th,\n.table > tbody > tr.success > th,\n.table > tfoot > tr.success > th {\n background-color: #dff0d8;\n}\n.table-hover > tbody > tr > td.success:hover,\n.table-hover > tbody > tr > th.success:hover,\n.table-hover > tbody > tr.success:hover > td,\n.table-hover > tbody > tr:hover > .success,\n.table-hover > tbody > tr.success:hover > th {\n background-color: #d0e9c6;\n}\n.table > thead > tr > td.info,\n.table > tbody > tr > td.info,\n.table > tfoot > tr > td.info,\n.table > thead > tr > th.info,\n.table > tbody > tr > th.info,\n.table > tfoot > tr > th.info,\n.table > thead > tr.info > td,\n.table > tbody > tr.info > td,\n.table > tfoot > tr.info > td,\n.table > thead > tr.info > th,\n.table > tbody > tr.info > th,\n.table > tfoot > tr.info > th {\n background-color: #d9edf7;\n}\n.table-hover > tbody > tr > td.info:hover,\n.table-hover > tbody > tr > th.info:hover,\n.table-hover > tbody > tr.info:hover > td,\n.table-hover > tbody > tr:hover > .info,\n.table-hover > tbody > tr.info:hover > th {\n background-color: #c4e3f3;\n}\n.table > thead > tr > td.warning,\n.table > tbody > tr > td.warning,\n.table > tfoot > tr > td.warning,\n.table > thead > tr > th.warning,\n.table > tbody > tr > th.warning,\n.table > tfoot > tr > th.warning,\n.table > thead > tr.warning > td,\n.table > tbody > tr.warning > td,\n.table > tfoot > tr.warning > td,\n.table > thead > tr.warning > th,\n.table > tbody > tr.warning > th,\n.table > tfoot > tr.warning > th {\n background-color: #fcf8e3;\n}\n.table-hover > tbody > tr > td.warning:hover,\n.table-hover > tbody > tr > th.warning:hover,\n.table-hover > tbody > tr.warning:hover > td,\n.table-hover > tbody > tr:hover > .warning,\n.table-hover > tbody > tr.warning:hover > th {\n background-color: #faf2cc;\n}\n.table > thead > tr > td.danger,\n.table > tbody > tr > td.danger,\n.table > tfoot > tr > td.danger,\n.table > thead > tr > th.danger,\n.table > tbody > tr > th.danger,\n.table > tfoot > tr > th.danger,\n.table > thead > tr.danger > td,\n.table > tbody > tr.danger > td,\n.table > tfoot > tr.danger > td,\n.table > thead > tr.danger > th,\n.table > tbody > tr.danger > th,\n.table > tfoot > tr.danger > th {\n background-color: #f2dede;\n}\n.table-hover > tbody > tr > td.danger:hover,\n.table-hover > tbody > tr > th.danger:hover,\n.table-hover > tbody > tr.danger:hover > td,\n.table-hover > tbody > tr:hover > .danger,\n.table-hover > tbody > tr.danger:hover > th {\n background-color: #ebcccc;\n}\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%;\n}\n@media screen and (max-width: 767px) {\n .table-responsive {\n width: 100%;\n margin-bottom: 15px;\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid #ddd;\n }\n .table-responsive > .table {\n margin-bottom: 0;\n }\n .table-responsive > .table > thead > tr > th,\n .table-responsive > .table > tbody > tr > th,\n .table-responsive > .table > tfoot > tr > th,\n .table-responsive > .table > thead > tr > td,\n .table-responsive > .table > tbody > tr > td,\n .table-responsive > .table > tfoot > tr > td {\n white-space: nowrap;\n }\n .table-responsive > .table-bordered {\n border: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:first-child,\n .table-responsive > .table-bordered > tbody > tr > th:first-child,\n .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n .table-responsive > .table-bordered > thead > tr > td:first-child,\n .table-responsive > .table-bordered > tbody > tr > td:first-child,\n .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n }\n .table-responsive > .table-bordered > thead > tr > th:last-child,\n .table-responsive > .table-bordered > tbody > tr > th:last-child,\n .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n .table-responsive > .table-bordered > thead > tr > td:last-child,\n .table-responsive > .table-bordered > tbody > tr > td:last-child,\n .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n }\n .table-responsive > .table-bordered > tbody > tr:last-child > th,\n .table-responsive > .table-bordered > tfoot > tr:last-child > th,\n .table-responsive > .table-bordered > tbody > tr:last-child > td,\n .table-responsive > .table-bordered > tfoot > tr:last-child > td {\n border-bottom: 0;\n }\n}\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n min-width: 0;\n}\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: 20px;\n font-size: 21px;\n line-height: inherit;\n color: #333333;\n border: 0;\n border-bottom: 1px solid #e5e5e5;\n}\nlabel {\n display: inline-block;\n max-width: 100%;\n margin-bottom: 5px;\n font-weight: bold;\n}\ninput[type=\"search\"] {\n -webkit-box-sizing: border-box;\n -moz-box-sizing: border-box;\n box-sizing: border-box;\n}\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9;\n line-height: normal;\n}\ninput[type=\"file\"] {\n display: block;\n}\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\nselect[multiple],\nselect[size] {\n height: auto;\n}\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n outline: thin dotted;\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\noutput {\n display: block;\n padding-top: 7px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n}\n.form-control {\n display: block;\n width: 100%;\n height: 34px;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n color: #555555;\n background-color: #fff;\n background-image: none;\n border: 1px solid #ccc;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n -webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;\n}\n.form-control:focus {\n border-color: #66afe9;\n outline: 0;\n -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, 0.6);\n}\n.form-control::-moz-placeholder {\n color: #999;\n opacity: 1;\n}\n.form-control:-ms-input-placeholder {\n color: #999;\n}\n.form-control::-webkit-input-placeholder {\n color: #999;\n}\n.form-control::-ms-expand {\n border: 0;\n background-color: transparent;\n}\n.form-control[disabled],\n.form-control[readonly],\nfieldset[disabled] .form-control {\n background-color: #eeeeee;\n opacity: 1;\n}\n.form-control[disabled],\nfieldset[disabled] .form-control {\n cursor: not-allowed;\n}\ntextarea.form-control {\n height: auto;\n}\ninput[type=\"search\"] {\n -webkit-appearance: none;\n}\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n input[type=\"date\"].form-control,\n input[type=\"time\"].form-control,\n input[type=\"datetime-local\"].form-control,\n input[type=\"month\"].form-control {\n line-height: 34px;\n }\n input[type=\"date\"].input-sm,\n input[type=\"time\"].input-sm,\n input[type=\"datetime-local\"].input-sm,\n input[type=\"month\"].input-sm,\n .input-group-sm input[type=\"date\"],\n .input-group-sm input[type=\"time\"],\n .input-group-sm input[type=\"datetime-local\"],\n .input-group-sm input[type=\"month\"] {\n line-height: 30px;\n }\n input[type=\"date\"].input-lg,\n input[type=\"time\"].input-lg,\n input[type=\"datetime-local\"].input-lg,\n input[type=\"month\"].input-lg,\n .input-group-lg input[type=\"date\"],\n .input-group-lg input[type=\"time\"],\n .input-group-lg input[type=\"datetime-local\"],\n .input-group-lg input[type=\"month\"] {\n line-height: 46px;\n }\n}\n.form-group {\n margin-bottom: 15px;\n}\n.radio,\n.checkbox {\n position: relative;\n display: block;\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.radio label,\n.checkbox label {\n min-height: 20px;\n padding-left: 20px;\n margin-bottom: 0;\n font-weight: normal;\n cursor: pointer;\n}\n.radio input[type=\"radio\"],\n.radio-inline input[type=\"radio\"],\n.checkbox input[type=\"checkbox\"],\n.checkbox-inline input[type=\"checkbox\"] {\n position: absolute;\n margin-left: -20px;\n margin-top: 4px \\9;\n}\n.radio + .radio,\n.checkbox + .checkbox {\n margin-top: -5px;\n}\n.radio-inline,\n.checkbox-inline {\n position: relative;\n display: inline-block;\n padding-left: 20px;\n margin-bottom: 0;\n vertical-align: middle;\n font-weight: normal;\n cursor: pointer;\n}\n.radio-inline + .radio-inline,\n.checkbox-inline + .checkbox-inline {\n margin-top: 0;\n margin-left: 10px;\n}\ninput[type=\"radio\"][disabled],\ninput[type=\"checkbox\"][disabled],\ninput[type=\"radio\"].disabled,\ninput[type=\"checkbox\"].disabled,\nfieldset[disabled] input[type=\"radio\"],\nfieldset[disabled] input[type=\"checkbox\"] {\n cursor: not-allowed;\n}\n.radio-inline.disabled,\n.checkbox-inline.disabled,\nfieldset[disabled] .radio-inline,\nfieldset[disabled] .checkbox-inline {\n cursor: not-allowed;\n}\n.radio.disabled label,\n.checkbox.disabled label,\nfieldset[disabled] .radio label,\nfieldset[disabled] .checkbox label {\n cursor: not-allowed;\n}\n.form-control-static {\n padding-top: 7px;\n padding-bottom: 7px;\n margin-bottom: 0;\n min-height: 34px;\n}\n.form-control-static.input-lg,\n.form-control-static.input-sm {\n padding-left: 0;\n padding-right: 0;\n}\n.input-sm {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-sm {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-sm,\nselect[multiple].input-sm {\n height: auto;\n}\n.form-group-sm .form-control {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.form-group-sm select.form-control {\n height: 30px;\n line-height: 30px;\n}\n.form-group-sm textarea.form-control,\n.form-group-sm select[multiple].form-control {\n height: auto;\n}\n.form-group-sm .form-control-static {\n height: 30px;\n min-height: 32px;\n padding: 6px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.input-lg {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-lg {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-lg,\nselect[multiple].input-lg {\n height: auto;\n}\n.form-group-lg .form-control {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.form-group-lg select.form-control {\n height: 46px;\n line-height: 46px;\n}\n.form-group-lg textarea.form-control,\n.form-group-lg select[multiple].form-control {\n height: auto;\n}\n.form-group-lg .form-control-static {\n height: 46px;\n min-height: 38px;\n padding: 11px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.has-feedback {\n position: relative;\n}\n.has-feedback .form-control {\n padding-right: 42.5px;\n}\n.form-control-feedback {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n display: block;\n width: 34px;\n height: 34px;\n line-height: 34px;\n text-align: center;\n pointer-events: none;\n}\n.input-lg + .form-control-feedback,\n.input-group-lg + .form-control-feedback,\n.form-group-lg .form-control + .form-control-feedback {\n width: 46px;\n height: 46px;\n line-height: 46px;\n}\n.input-sm + .form-control-feedback,\n.input-group-sm + .form-control-feedback,\n.form-group-sm .form-control + .form-control-feedback {\n width: 30px;\n height: 30px;\n line-height: 30px;\n}\n.has-success .help-block,\n.has-success .control-label,\n.has-success .radio,\n.has-success .checkbox,\n.has-success .radio-inline,\n.has-success .checkbox-inline,\n.has-success.radio label,\n.has-success.checkbox label,\n.has-success.radio-inline label,\n.has-success.checkbox-inline label {\n color: #3c763d;\n}\n.has-success .form-control {\n border-color: #3c763d;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-success .form-control:focus {\n border-color: #2b542c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;\n}\n.has-success .input-group-addon {\n color: #3c763d;\n border-color: #3c763d;\n background-color: #dff0d8;\n}\n.has-success .form-control-feedback {\n color: #3c763d;\n}\n.has-warning .help-block,\n.has-warning .control-label,\n.has-warning .radio,\n.has-warning .checkbox,\n.has-warning .radio-inline,\n.has-warning .checkbox-inline,\n.has-warning.radio label,\n.has-warning.checkbox label,\n.has-warning.radio-inline label,\n.has-warning.checkbox-inline label {\n color: #8a6d3b;\n}\n.has-warning .form-control {\n border-color: #8a6d3b;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-warning .form-control:focus {\n border-color: #66512c;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;\n}\n.has-warning .input-group-addon {\n color: #8a6d3b;\n border-color: #8a6d3b;\n background-color: #fcf8e3;\n}\n.has-warning .form-control-feedback {\n color: #8a6d3b;\n}\n.has-error .help-block,\n.has-error .control-label,\n.has-error .radio,\n.has-error .checkbox,\n.has-error .radio-inline,\n.has-error .checkbox-inline,\n.has-error.radio label,\n.has-error.checkbox label,\n.has-error.radio-inline label,\n.has-error.checkbox-inline label {\n color: #a94442;\n}\n.has-error .form-control {\n border-color: #a94442;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);\n}\n.has-error .form-control:focus {\n border-color: #843534;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;\n}\n.has-error .input-group-addon {\n color: #a94442;\n border-color: #a94442;\n background-color: #f2dede;\n}\n.has-error .form-control-feedback {\n color: #a94442;\n}\n.has-feedback label ~ .form-control-feedback {\n top: 25px;\n}\n.has-feedback label.sr-only ~ .form-control-feedback {\n top: 0;\n}\n.help-block {\n display: block;\n margin-top: 5px;\n margin-bottom: 10px;\n color: #737373;\n}\n@media (min-width: 768px) {\n .form-inline .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .form-inline .form-control-static {\n display: inline-block;\n }\n .form-inline .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .form-inline .input-group .input-group-addon,\n .form-inline .input-group .input-group-btn,\n .form-inline .input-group .form-control {\n width: auto;\n }\n .form-inline .input-group > .form-control {\n width: 100%;\n }\n .form-inline .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio,\n .form-inline .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .form-inline .radio label,\n .form-inline .checkbox label {\n padding-left: 0;\n }\n .form-inline .radio input[type=\"radio\"],\n .form-inline .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .form-inline .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox,\n.form-horizontal .radio-inline,\n.form-horizontal .checkbox-inline {\n margin-top: 0;\n margin-bottom: 0;\n padding-top: 7px;\n}\n.form-horizontal .radio,\n.form-horizontal .checkbox {\n min-height: 27px;\n}\n.form-horizontal .form-group {\n margin-left: -15px;\n margin-right: -15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .control-label {\n text-align: right;\n margin-bottom: 0;\n padding-top: 7px;\n }\n}\n.form-horizontal .has-feedback .form-control-feedback {\n right: 15px;\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-lg .control-label {\n padding-top: 11px;\n font-size: 18px;\n }\n}\n@media (min-width: 768px) {\n .form-horizontal .form-group-sm .control-label {\n padding-top: 6px;\n font-size: 12px;\n }\n}\n.btn {\n display: inline-block;\n margin-bottom: 0;\n font-weight: normal;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none;\n border: 1px solid transparent;\n white-space: nowrap;\n padding: 6px 12px;\n font-size: 14px;\n line-height: 1.42857143;\n border-radius: 4px;\n -webkit-user-select: none;\n -moz-user-select: none;\n -ms-user-select: none;\n user-select: none;\n}\n.btn:focus,\n.btn:active:focus,\n.btn.active:focus,\n.btn.focus,\n.btn:active.focus,\n.btn.active.focus {\n outline: thin dotted;\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n.btn:hover,\n.btn:focus,\n.btn.focus {\n color: #333;\n text-decoration: none;\n}\n.btn:active,\n.btn.active {\n outline: 0;\n background-image: none;\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn.disabled,\n.btn[disabled],\nfieldset[disabled] .btn {\n cursor: not-allowed;\n opacity: 0.65;\n filter: alpha(opacity=65);\n -webkit-box-shadow: none;\n box-shadow: none;\n}\na.btn.disabled,\nfieldset[disabled] a.btn {\n pointer-events: none;\n}\n.btn-default {\n color: #333;\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default:focus,\n.btn-default.focus {\n color: #333;\n background-color: #e6e6e6;\n border-color: #8c8c8c;\n}\n.btn-default:hover {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n color: #333;\n background-color: #e6e6e6;\n border-color: #adadad;\n}\n.btn-default:active:hover,\n.btn-default.active:hover,\n.open > .dropdown-toggle.btn-default:hover,\n.btn-default:active:focus,\n.btn-default.active:focus,\n.open > .dropdown-toggle.btn-default:focus,\n.btn-default:active.focus,\n.btn-default.active.focus,\n.open > .dropdown-toggle.btn-default.focus {\n color: #333;\n background-color: #d4d4d4;\n border-color: #8c8c8c;\n}\n.btn-default:active,\n.btn-default.active,\n.open > .dropdown-toggle.btn-default {\n background-image: none;\n}\n.btn-default.disabled:hover,\n.btn-default[disabled]:hover,\nfieldset[disabled] .btn-default:hover,\n.btn-default.disabled:focus,\n.btn-default[disabled]:focus,\nfieldset[disabled] .btn-default:focus,\n.btn-default.disabled.focus,\n.btn-default[disabled].focus,\nfieldset[disabled] .btn-default.focus {\n background-color: #fff;\n border-color: #ccc;\n}\n.btn-default .badge {\n color: #fff;\n background-color: #333;\n}\n.btn-primary {\n color: #fff;\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary:focus,\n.btn-primary.focus {\n color: #fff;\n background-color: #286090;\n border-color: #122b40;\n}\n.btn-primary:hover {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n color: #fff;\n background-color: #286090;\n border-color: #204d74;\n}\n.btn-primary:active:hover,\n.btn-primary.active:hover,\n.open > .dropdown-toggle.btn-primary:hover,\n.btn-primary:active:focus,\n.btn-primary.active:focus,\n.open > .dropdown-toggle.btn-primary:focus,\n.btn-primary:active.focus,\n.btn-primary.active.focus,\n.open > .dropdown-toggle.btn-primary.focus {\n color: #fff;\n background-color: #204d74;\n border-color: #122b40;\n}\n.btn-primary:active,\n.btn-primary.active,\n.open > .dropdown-toggle.btn-primary {\n background-image: none;\n}\n.btn-primary.disabled:hover,\n.btn-primary[disabled]:hover,\nfieldset[disabled] .btn-primary:hover,\n.btn-primary.disabled:focus,\n.btn-primary[disabled]:focus,\nfieldset[disabled] .btn-primary:focus,\n.btn-primary.disabled.focus,\n.btn-primary[disabled].focus,\nfieldset[disabled] .btn-primary.focus {\n background-color: #337ab7;\n border-color: #2e6da4;\n}\n.btn-primary .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.btn-success {\n color: #fff;\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success:focus,\n.btn-success.focus {\n color: #fff;\n background-color: #449d44;\n border-color: #255625;\n}\n.btn-success:hover {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n color: #fff;\n background-color: #449d44;\n border-color: #398439;\n}\n.btn-success:active:hover,\n.btn-success.active:hover,\n.open > .dropdown-toggle.btn-success:hover,\n.btn-success:active:focus,\n.btn-success.active:focus,\n.open > .dropdown-toggle.btn-success:focus,\n.btn-success:active.focus,\n.btn-success.active.focus,\n.open > .dropdown-toggle.btn-success.focus {\n color: #fff;\n background-color: #398439;\n border-color: #255625;\n}\n.btn-success:active,\n.btn-success.active,\n.open > .dropdown-toggle.btn-success {\n background-image: none;\n}\n.btn-success.disabled:hover,\n.btn-success[disabled]:hover,\nfieldset[disabled] .btn-success:hover,\n.btn-success.disabled:focus,\n.btn-success[disabled]:focus,\nfieldset[disabled] .btn-success:focus,\n.btn-success.disabled.focus,\n.btn-success[disabled].focus,\nfieldset[disabled] .btn-success.focus {\n background-color: #5cb85c;\n border-color: #4cae4c;\n}\n.btn-success .badge {\n color: #5cb85c;\n background-color: #fff;\n}\n.btn-info {\n color: #fff;\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info:focus,\n.btn-info.focus {\n color: #fff;\n background-color: #31b0d5;\n border-color: #1b6d85;\n}\n.btn-info:hover {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n color: #fff;\n background-color: #31b0d5;\n border-color: #269abc;\n}\n.btn-info:active:hover,\n.btn-info.active:hover,\n.open > .dropdown-toggle.btn-info:hover,\n.btn-info:active:focus,\n.btn-info.active:focus,\n.open > .dropdown-toggle.btn-info:focus,\n.btn-info:active.focus,\n.btn-info.active.focus,\n.open > .dropdown-toggle.btn-info.focus {\n color: #fff;\n background-color: #269abc;\n border-color: #1b6d85;\n}\n.btn-info:active,\n.btn-info.active,\n.open > .dropdown-toggle.btn-info {\n background-image: none;\n}\n.btn-info.disabled:hover,\n.btn-info[disabled]:hover,\nfieldset[disabled] .btn-info:hover,\n.btn-info.disabled:focus,\n.btn-info[disabled]:focus,\nfieldset[disabled] .btn-info:focus,\n.btn-info.disabled.focus,\n.btn-info[disabled].focus,\nfieldset[disabled] .btn-info.focus {\n background-color: #5bc0de;\n border-color: #46b8da;\n}\n.btn-info .badge {\n color: #5bc0de;\n background-color: #fff;\n}\n.btn-warning {\n color: #fff;\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning:focus,\n.btn-warning.focus {\n color: #fff;\n background-color: #ec971f;\n border-color: #985f0d;\n}\n.btn-warning:hover {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n color: #fff;\n background-color: #ec971f;\n border-color: #d58512;\n}\n.btn-warning:active:hover,\n.btn-warning.active:hover,\n.open > .dropdown-toggle.btn-warning:hover,\n.btn-warning:active:focus,\n.btn-warning.active:focus,\n.open > .dropdown-toggle.btn-warning:focus,\n.btn-warning:active.focus,\n.btn-warning.active.focus,\n.open > .dropdown-toggle.btn-warning.focus {\n color: #fff;\n background-color: #d58512;\n border-color: #985f0d;\n}\n.btn-warning:active,\n.btn-warning.active,\n.open > .dropdown-toggle.btn-warning {\n background-image: none;\n}\n.btn-warning.disabled:hover,\n.btn-warning[disabled]:hover,\nfieldset[disabled] .btn-warning:hover,\n.btn-warning.disabled:focus,\n.btn-warning[disabled]:focus,\nfieldset[disabled] .btn-warning:focus,\n.btn-warning.disabled.focus,\n.btn-warning[disabled].focus,\nfieldset[disabled] .btn-warning.focus {\n background-color: #f0ad4e;\n border-color: #eea236;\n}\n.btn-warning .badge {\n color: #f0ad4e;\n background-color: #fff;\n}\n.btn-danger {\n color: #fff;\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger:focus,\n.btn-danger.focus {\n color: #fff;\n background-color: #c9302c;\n border-color: #761c19;\n}\n.btn-danger:hover {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n color: #fff;\n background-color: #c9302c;\n border-color: #ac2925;\n}\n.btn-danger:active:hover,\n.btn-danger.active:hover,\n.open > .dropdown-toggle.btn-danger:hover,\n.btn-danger:active:focus,\n.btn-danger.active:focus,\n.open > .dropdown-toggle.btn-danger:focus,\n.btn-danger:active.focus,\n.btn-danger.active.focus,\n.open > .dropdown-toggle.btn-danger.focus {\n color: #fff;\n background-color: #ac2925;\n border-color: #761c19;\n}\n.btn-danger:active,\n.btn-danger.active,\n.open > .dropdown-toggle.btn-danger {\n background-image: none;\n}\n.btn-danger.disabled:hover,\n.btn-danger[disabled]:hover,\nfieldset[disabled] .btn-danger:hover,\n.btn-danger.disabled:focus,\n.btn-danger[disabled]:focus,\nfieldset[disabled] .btn-danger:focus,\n.btn-danger.disabled.focus,\n.btn-danger[disabled].focus,\nfieldset[disabled] .btn-danger.focus {\n background-color: #d9534f;\n border-color: #d43f3a;\n}\n.btn-danger .badge {\n color: #d9534f;\n background-color: #fff;\n}\n.btn-link {\n color: #337ab7;\n font-weight: normal;\n border-radius: 0;\n}\n.btn-link,\n.btn-link:active,\n.btn-link.active,\n.btn-link[disabled],\nfieldset[disabled] .btn-link {\n background-color: transparent;\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn-link,\n.btn-link:hover,\n.btn-link:focus,\n.btn-link:active {\n border-color: transparent;\n}\n.btn-link:hover,\n.btn-link:focus {\n color: #23527c;\n text-decoration: underline;\n background-color: transparent;\n}\n.btn-link[disabled]:hover,\nfieldset[disabled] .btn-link:hover,\n.btn-link[disabled]:focus,\nfieldset[disabled] .btn-link:focus {\n color: #777777;\n text-decoration: none;\n}\n.btn-lg,\n.btn-group-lg > .btn {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\n.btn-sm,\n.btn-group-sm > .btn {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-xs,\n.btn-group-xs > .btn {\n padding: 1px 5px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\n.btn-block {\n display: block;\n width: 100%;\n}\n.btn-block + .btn-block {\n margin-top: 5px;\n}\ninput[type=\"submit\"].btn-block,\ninput[type=\"reset\"].btn-block,\ninput[type=\"button\"].btn-block {\n width: 100%;\n}\n.fade {\n opacity: 0;\n -webkit-transition: opacity 0.15s linear;\n -o-transition: opacity 0.15s linear;\n transition: opacity 0.15s linear;\n}\n.fade.in {\n opacity: 1;\n}\n.collapse {\n display: none;\n}\n.collapse.in {\n display: block;\n}\ntr.collapse.in {\n display: table-row;\n}\ntbody.collapse.in {\n display: table-row-group;\n}\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n -webkit-transition-property: height, visibility;\n transition-property: height, visibility;\n -webkit-transition-duration: 0.35s;\n transition-duration: 0.35s;\n -webkit-transition-timing-function: ease;\n transition-timing-function: ease;\n}\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: 4px dashed;\n border-top: 4px solid \\9;\n border-right: 4px solid transparent;\n border-left: 4px solid transparent;\n}\n.dropup,\n.dropdown {\n position: relative;\n}\n.dropdown-toggle:focus {\n outline: 0;\n}\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: 1000;\n display: none;\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0;\n list-style: none;\n font-size: 14px;\n text-align: left;\n background-color: #fff;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, 0.15);\n border-radius: 4px;\n -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);\n background-clip: padding-box;\n}\n.dropdown-menu.pull-right {\n right: 0;\n left: auto;\n}\n.dropdown-menu .divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.dropdown-menu > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: 1.42857143;\n color: #333333;\n white-space: nowrap;\n}\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n text-decoration: none;\n color: #262626;\n background-color: #f5f5f5;\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n color: #fff;\n text-decoration: none;\n outline: 0;\n background-color: #337ab7;\n}\n.dropdown-menu > .disabled > a,\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n color: #777777;\n}\n.dropdown-menu > .disabled > a:hover,\n.dropdown-menu > .disabled > a:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none;\n filter: progid:DXImageTransform.Microsoft.gradient(enabled = false);\n cursor: not-allowed;\n}\n.open > .dropdown-menu {\n display: block;\n}\n.open > a {\n outline: 0;\n}\n.dropdown-menu-right {\n left: auto;\n right: 0;\n}\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: 12px;\n line-height: 1.42857143;\n color: #777777;\n white-space: nowrap;\n}\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: 990;\n}\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n.dropup .caret,\n.navbar-fixed-bottom .dropdown .caret {\n border-top: 0;\n border-bottom: 4px dashed;\n border-bottom: 4px solid \\9;\n content: \"\";\n}\n.dropup .dropdown-menu,\n.navbar-fixed-bottom .dropdown .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n}\n@media (min-width: 768px) {\n .navbar-right .dropdown-menu {\n left: auto;\n right: 0;\n }\n .navbar-right .dropdown-menu-left {\n left: 0;\n right: auto;\n }\n}\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle;\n}\n.btn-group > .btn,\n.btn-group-vertical > .btn {\n position: relative;\n float: left;\n}\n.btn-group > .btn:hover,\n.btn-group-vertical > .btn:hover,\n.btn-group > .btn:focus,\n.btn-group-vertical > .btn:focus,\n.btn-group > .btn:active,\n.btn-group-vertical > .btn:active,\n.btn-group > .btn.active,\n.btn-group-vertical > .btn.active {\n z-index: 2;\n}\n.btn-group .btn + .btn,\n.btn-group .btn + .btn-group,\n.btn-group .btn-group + .btn,\n.btn-group .btn-group + .btn-group {\n margin-left: -1px;\n}\n.btn-toolbar {\n margin-left: -5px;\n}\n.btn-toolbar .btn,\n.btn-toolbar .btn-group,\n.btn-toolbar .input-group {\n float: left;\n}\n.btn-toolbar > .btn,\n.btn-toolbar > .btn-group,\n.btn-toolbar > .input-group {\n margin-left: 5px;\n}\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n.btn-group > .btn:first-child {\n margin-left: 0;\n}\n.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n.btn-group.open .dropdown-toggle {\n -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n}\n.btn-group.open .dropdown-toggle.btn-link {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n.btn .caret {\n margin-left: 0;\n}\n.btn-lg .caret {\n border-width: 5px 5px 0;\n border-bottom-width: 0;\n}\n.dropup .btn-lg .caret {\n border-width: 0 5px 5px;\n}\n.btn-group-vertical > .btn,\n.btn-group-vertical > .btn-group,\n.btn-group-vertical > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n}\n.btn-group-vertical > .btn-group > .btn {\n float: none;\n}\n.btn-group-vertical > .btn + .btn,\n.btn-group-vertical > .btn + .btn-group,\n.btn-group-vertical > .btn-group + .btn,\n.btn-group-vertical > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n}\n.btn-group-vertical > .btn:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.btn-group-vertical > .btn:first-child:not(:last-child) {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn:last-child:not(:first-child) {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child,\n.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle {\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n}\n.btn-group-justified > .btn,\n.btn-group-justified > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n}\n.btn-group-justified > .btn-group .btn {\n width: 100%;\n}\n.btn-group-justified > .btn-group .dropdown-menu {\n left: auto;\n}\n[data-toggle=\"buttons\"] > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"radio\"],\n[data-toggle=\"buttons\"] > .btn input[type=\"checkbox\"],\n[data-toggle=\"buttons\"] > .btn-group > .btn input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0, 0, 0, 0);\n pointer-events: none;\n}\n.input-group {\n position: relative;\n display: table;\n border-collapse: separate;\n}\n.input-group[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n}\n.input-group .form-control {\n position: relative;\n z-index: 2;\n float: left;\n width: 100%;\n margin-bottom: 0;\n}\n.input-group .form-control:focus {\n z-index: 3;\n}\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n border-radius: 6px;\n}\nselect.input-group-lg > .form-control,\nselect.input-group-lg > .input-group-addon,\nselect.input-group-lg > .input-group-btn > .btn {\n height: 46px;\n line-height: 46px;\n}\ntextarea.input-group-lg > .form-control,\ntextarea.input-group-lg > .input-group-addon,\ntextarea.input-group-lg > .input-group-btn > .btn,\nselect[multiple].input-group-lg > .form-control,\nselect[multiple].input-group-lg > .input-group-addon,\nselect[multiple].input-group-lg > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n border-radius: 3px;\n}\nselect.input-group-sm > .form-control,\nselect.input-group-sm > .input-group-addon,\nselect.input-group-sm > .input-group-btn > .btn {\n height: 30px;\n line-height: 30px;\n}\ntextarea.input-group-sm > .form-control,\ntextarea.input-group-sm > .input-group-addon,\ntextarea.input-group-sm > .input-group-btn > .btn,\nselect[multiple].input-group-sm > .form-control,\nselect[multiple].input-group-sm > .input-group-addon,\nselect[multiple].input-group-sm > .input-group-btn > .btn {\n height: auto;\n}\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n}\n.input-group-addon:not(:first-child):not(:last-child),\n.input-group-btn:not(:first-child):not(:last-child),\n.input-group .form-control:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle;\n}\n.input-group-addon {\n padding: 6px 12px;\n font-size: 14px;\n font-weight: normal;\n line-height: 1;\n color: #555555;\n text-align: center;\n background-color: #eeeeee;\n border: 1px solid #ccc;\n border-radius: 4px;\n}\n.input-group-addon.input-sm {\n padding: 5px 10px;\n font-size: 12px;\n border-radius: 3px;\n}\n.input-group-addon.input-lg {\n padding: 10px 16px;\n font-size: 18px;\n border-radius: 6px;\n}\n.input-group-addon input[type=\"radio\"],\n.input-group-addon input[type=\"checkbox\"] {\n margin-top: 0;\n}\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n border-bottom-right-radius: 0;\n border-top-right-radius: 0;\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n border-bottom-left-radius: 0;\n border-top-left-radius: 0;\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n.input-group-btn {\n position: relative;\n font-size: 0;\n white-space: nowrap;\n}\n.input-group-btn > .btn {\n position: relative;\n}\n.input-group-btn > .btn + .btn {\n margin-left: -1px;\n}\n.input-group-btn > .btn:hover,\n.input-group-btn > .btn:focus,\n.input-group-btn > .btn:active {\n z-index: 2;\n}\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group {\n margin-right: -1px;\n}\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group {\n z-index: 2;\n margin-left: -1px;\n}\n.nav {\n margin-bottom: 0;\n padding-left: 0;\n list-style: none;\n}\n.nav > li {\n position: relative;\n display: block;\n}\n.nav > li > a {\n position: relative;\n display: block;\n padding: 10px 15px;\n}\n.nav > li > a:hover,\n.nav > li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.nav > li.disabled > a {\n color: #777777;\n}\n.nav > li.disabled > a:hover,\n.nav > li.disabled > a:focus {\n color: #777777;\n text-decoration: none;\n background-color: transparent;\n cursor: not-allowed;\n}\n.nav .open > a,\n.nav .open > a:hover,\n.nav .open > a:focus {\n background-color: #eeeeee;\n border-color: #337ab7;\n}\n.nav .nav-divider {\n height: 1px;\n margin: 9px 0;\n overflow: hidden;\n background-color: #e5e5e5;\n}\n.nav > li > a > img {\n max-width: none;\n}\n.nav-tabs {\n border-bottom: 1px solid #ddd;\n}\n.nav-tabs > li {\n float: left;\n margin-bottom: -1px;\n}\n.nav-tabs > li > a {\n margin-right: 2px;\n line-height: 1.42857143;\n border: 1px solid transparent;\n border-radius: 4px 4px 0 0;\n}\n.nav-tabs > li > a:hover {\n border-color: #eeeeee #eeeeee #ddd;\n}\n.nav-tabs > li.active > a,\n.nav-tabs > li.active > a:hover,\n.nav-tabs > li.active > a:focus {\n color: #555555;\n background-color: #fff;\n border: 1px solid #ddd;\n border-bottom-color: transparent;\n cursor: default;\n}\n.nav-tabs.nav-justified {\n width: 100%;\n border-bottom: 0;\n}\n.nav-tabs.nav-justified > li {\n float: none;\n}\n.nav-tabs.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-tabs.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-tabs.nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs.nav-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs.nav-justified > .active > a,\n.nav-tabs.nav-justified > .active > a:hover,\n.nav-tabs.nav-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs.nav-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs.nav-justified > .active > a,\n .nav-tabs.nav-justified > .active > a:hover,\n .nav-tabs.nav-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.nav-pills > li {\n float: left;\n}\n.nav-pills > li > a {\n border-radius: 4px;\n}\n.nav-pills > li + li {\n margin-left: 2px;\n}\n.nav-pills > li.active > a,\n.nav-pills > li.active > a:hover,\n.nav-pills > li.active > a:focus {\n color: #fff;\n background-color: #337ab7;\n}\n.nav-stacked > li {\n float: none;\n}\n.nav-stacked > li + li {\n margin-top: 2px;\n margin-left: 0;\n}\n.nav-justified {\n width: 100%;\n}\n.nav-justified > li {\n float: none;\n}\n.nav-justified > li > a {\n text-align: center;\n margin-bottom: 5px;\n}\n.nav-justified > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n}\n@media (min-width: 768px) {\n .nav-justified > li {\n display: table-cell;\n width: 1%;\n }\n .nav-justified > li > a {\n margin-bottom: 0;\n }\n}\n.nav-tabs-justified {\n border-bottom: 0;\n}\n.nav-tabs-justified > li > a {\n margin-right: 0;\n border-radius: 4px;\n}\n.nav-tabs-justified > .active > a,\n.nav-tabs-justified > .active > a:hover,\n.nav-tabs-justified > .active > a:focus {\n border: 1px solid #ddd;\n}\n@media (min-width: 768px) {\n .nav-tabs-justified > li > a {\n border-bottom: 1px solid #ddd;\n border-radius: 4px 4px 0 0;\n }\n .nav-tabs-justified > .active > a,\n .nav-tabs-justified > .active > a:hover,\n .nav-tabs-justified > .active > a:focus {\n border-bottom-color: #fff;\n }\n}\n.tab-content > .tab-pane {\n display: none;\n}\n.tab-content > .active {\n display: block;\n}\n.nav-tabs .dropdown-menu {\n margin-top: -1px;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar {\n position: relative;\n min-height: 50px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n}\n@media (min-width: 768px) {\n .navbar {\n border-radius: 4px;\n }\n}\n@media (min-width: 768px) {\n .navbar-header {\n float: left;\n }\n}\n.navbar-collapse {\n overflow-x: visible;\n padding-right: 15px;\n padding-left: 15px;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);\n -webkit-overflow-scrolling: touch;\n}\n.navbar-collapse.in {\n overflow-y: auto;\n}\n@media (min-width: 768px) {\n .navbar-collapse {\n width: auto;\n border-top: 0;\n box-shadow: none;\n }\n .navbar-collapse.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0;\n overflow: visible !important;\n }\n .navbar-collapse.in {\n overflow-y: visible;\n }\n .navbar-fixed-top .navbar-collapse,\n .navbar-static-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n padding-left: 0;\n padding-right: 0;\n }\n}\n.navbar-fixed-top .navbar-collapse,\n.navbar-fixed-bottom .navbar-collapse {\n max-height: 340px;\n}\n@media (max-device-width: 480px) and (orientation: landscape) {\n .navbar-fixed-top .navbar-collapse,\n .navbar-fixed-bottom .navbar-collapse {\n max-height: 200px;\n }\n}\n.container > .navbar-header,\n.container-fluid > .navbar-header,\n.container > .navbar-collapse,\n.container-fluid > .navbar-collapse {\n margin-right: -15px;\n margin-left: -15px;\n}\n@media (min-width: 768px) {\n .container > .navbar-header,\n .container-fluid > .navbar-header,\n .container > .navbar-collapse,\n .container-fluid > .navbar-collapse {\n margin-right: 0;\n margin-left: 0;\n }\n}\n.navbar-static-top {\n z-index: 1000;\n border-width: 0 0 1px;\n}\n@media (min-width: 768px) {\n .navbar-static-top {\n border-radius: 0;\n }\n}\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: 1030;\n}\n@media (min-width: 768px) {\n .navbar-fixed-top,\n .navbar-fixed-bottom {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0;\n border-width: 1px 0 0;\n}\n.navbar-brand {\n float: left;\n padding: 15px 15px;\n font-size: 18px;\n line-height: 20px;\n height: 50px;\n}\n.navbar-brand:hover,\n.navbar-brand:focus {\n text-decoration: none;\n}\n.navbar-brand > img {\n display: block;\n}\n@media (min-width: 768px) {\n .navbar > .container .navbar-brand,\n .navbar > .container-fluid .navbar-brand {\n margin-left: -15px;\n }\n}\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: 15px;\n padding: 9px 10px;\n margin-top: 8px;\n margin-bottom: 8px;\n background-color: transparent;\n background-image: none;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.navbar-toggle:focus {\n outline: 0;\n}\n.navbar-toggle .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n}\n.navbar-toggle .icon-bar + .icon-bar {\n margin-top: 4px;\n}\n@media (min-width: 768px) {\n .navbar-toggle {\n display: none;\n }\n}\n.navbar-nav {\n margin: 7.5px -15px;\n}\n.navbar-nav > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: 20px;\n}\n@media (max-width: 767px) {\n .navbar-nav .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n }\n .navbar-nav .open .dropdown-menu > li > a,\n .navbar-nav .open .dropdown-menu .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n .navbar-nav .open .dropdown-menu > li > a {\n line-height: 20px;\n }\n .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-nav .open .dropdown-menu > li > a:focus {\n background-image: none;\n }\n}\n@media (min-width: 768px) {\n .navbar-nav {\n float: left;\n margin: 0;\n }\n .navbar-nav > li {\n float: left;\n }\n .navbar-nav > li > a {\n padding-top: 15px;\n padding-bottom: 15px;\n }\n}\n.navbar-form {\n margin-left: -15px;\n margin-right: -15px;\n padding: 10px 15px;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.1);\n margin-top: 8px;\n margin-bottom: 8px;\n}\n@media (min-width: 768px) {\n .navbar-form .form-group {\n display: inline-block;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .form-control {\n display: inline-block;\n width: auto;\n vertical-align: middle;\n }\n .navbar-form .form-control-static {\n display: inline-block;\n }\n .navbar-form .input-group {\n display: inline-table;\n vertical-align: middle;\n }\n .navbar-form .input-group .input-group-addon,\n .navbar-form .input-group .input-group-btn,\n .navbar-form .input-group .form-control {\n width: auto;\n }\n .navbar-form .input-group > .form-control {\n width: 100%;\n }\n .navbar-form .control-label {\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio,\n .navbar-form .checkbox {\n display: inline-block;\n margin-top: 0;\n margin-bottom: 0;\n vertical-align: middle;\n }\n .navbar-form .radio label,\n .navbar-form .checkbox label {\n padding-left: 0;\n }\n .navbar-form .radio input[type=\"radio\"],\n .navbar-form .checkbox input[type=\"checkbox\"] {\n position: relative;\n margin-left: 0;\n }\n .navbar-form .has-feedback .form-control-feedback {\n top: 0;\n }\n}\n@media (max-width: 767px) {\n .navbar-form .form-group {\n margin-bottom: 5px;\n }\n .navbar-form .form-group:last-child {\n margin-bottom: 0;\n }\n}\n@media (min-width: 768px) {\n .navbar-form {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n -webkit-box-shadow: none;\n box-shadow: none;\n }\n}\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n border-bottom-right-radius: 0;\n border-bottom-left-radius: 0;\n}\n.navbar-btn {\n margin-top: 8px;\n margin-bottom: 8px;\n}\n.navbar-btn.btn-sm {\n margin-top: 10px;\n margin-bottom: 10px;\n}\n.navbar-btn.btn-xs {\n margin-top: 14px;\n margin-bottom: 14px;\n}\n.navbar-text {\n margin-top: 15px;\n margin-bottom: 15px;\n}\n@media (min-width: 768px) {\n .navbar-text {\n float: left;\n margin-left: 15px;\n margin-right: 15px;\n }\n}\n@media (min-width: 768px) {\n .navbar-left {\n float: left !important;\n }\n .navbar-right {\n float: right !important;\n margin-right: -15px;\n }\n .navbar-right ~ .navbar-right {\n margin-right: 0;\n }\n}\n.navbar-default {\n background-color: #f8f8f8;\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-brand {\n color: #777;\n}\n.navbar-default .navbar-brand:hover,\n.navbar-default .navbar-brand:focus {\n color: #5e5e5e;\n background-color: transparent;\n}\n.navbar-default .navbar-text {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a {\n color: #777;\n}\n.navbar-default .navbar-nav > li > a:hover,\n.navbar-default .navbar-nav > li > a:focus {\n color: #333;\n background-color: transparent;\n}\n.navbar-default .navbar-nav > .active > a,\n.navbar-default .navbar-nav > .active > a:hover,\n.navbar-default .navbar-nav > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .disabled > a,\n.navbar-default .navbar-nav > .disabled > a:hover,\n.navbar-default .navbar-nav > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n}\n.navbar-default .navbar-toggle {\n border-color: #ddd;\n}\n.navbar-default .navbar-toggle:hover,\n.navbar-default .navbar-toggle:focus {\n background-color: #ddd;\n}\n.navbar-default .navbar-toggle .icon-bar {\n background-color: #888;\n}\n.navbar-default .navbar-collapse,\n.navbar-default .navbar-form {\n border-color: #e7e7e7;\n}\n.navbar-default .navbar-nav > .open > a,\n.navbar-default .navbar-nav > .open > a:hover,\n.navbar-default .navbar-nav > .open > a:focus {\n background-color: #e7e7e7;\n color: #555;\n}\n@media (max-width: 767px) {\n .navbar-default .navbar-nav .open .dropdown-menu > li > a {\n color: #777;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #333;\n background-color: transparent;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #555;\n background-color: #e7e7e7;\n }\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #ccc;\n background-color: transparent;\n }\n}\n.navbar-default .navbar-link {\n color: #777;\n}\n.navbar-default .navbar-link:hover {\n color: #333;\n}\n.navbar-default .btn-link {\n color: #777;\n}\n.navbar-default .btn-link:hover,\n.navbar-default .btn-link:focus {\n color: #333;\n}\n.navbar-default .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-default .btn-link:hover,\n.navbar-default .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-default .btn-link:focus {\n color: #ccc;\n}\n.navbar-inverse {\n background-color: #222;\n border-color: #080808;\n}\n.navbar-inverse .navbar-brand {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-brand:hover,\n.navbar-inverse .navbar-brand:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-text {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-nav > li > a:hover,\n.navbar-inverse .navbar-nav > li > a:focus {\n color: #fff;\n background-color: transparent;\n}\n.navbar-inverse .navbar-nav > .active > a,\n.navbar-inverse .navbar-nav > .active > a:hover,\n.navbar-inverse .navbar-nav > .active > a:focus {\n color: #fff;\n background-color: #080808;\n}\n.navbar-inverse .navbar-nav > .disabled > a,\n.navbar-inverse .navbar-nav > .disabled > a:hover,\n.navbar-inverse .navbar-nav > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n}\n.navbar-inverse .navbar-toggle {\n border-color: #333;\n}\n.navbar-inverse .navbar-toggle:hover,\n.navbar-inverse .navbar-toggle:focus {\n background-color: #333;\n}\n.navbar-inverse .navbar-toggle .icon-bar {\n background-color: #fff;\n}\n.navbar-inverse .navbar-collapse,\n.navbar-inverse .navbar-form {\n border-color: #101010;\n}\n.navbar-inverse .navbar-nav > .open > a,\n.navbar-inverse .navbar-nav > .open > a:hover,\n.navbar-inverse .navbar-nav > .open > a:focus {\n background-color: #080808;\n color: #fff;\n}\n@media (max-width: 767px) {\n .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header {\n border-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu .divider {\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a {\n color: #9d9d9d;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus {\n color: #fff;\n background-color: transparent;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus {\n color: #fff;\n background-color: #080808;\n }\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover,\n .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus {\n color: #444;\n background-color: transparent;\n }\n}\n.navbar-inverse .navbar-link {\n color: #9d9d9d;\n}\n.navbar-inverse .navbar-link:hover {\n color: #fff;\n}\n.navbar-inverse .btn-link {\n color: #9d9d9d;\n}\n.navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link:focus {\n color: #fff;\n}\n.navbar-inverse .btn-link[disabled]:hover,\nfieldset[disabled] .navbar-inverse .btn-link:hover,\n.navbar-inverse .btn-link[disabled]:focus,\nfieldset[disabled] .navbar-inverse .btn-link:focus {\n color: #444;\n}\n.breadcrumb {\n padding: 8px 15px;\n margin-bottom: 20px;\n list-style: none;\n background-color: #f5f5f5;\n border-radius: 4px;\n}\n.breadcrumb > li {\n display: inline-block;\n}\n.breadcrumb > li + li:before {\n content: \"/\\00a0\";\n padding: 0 5px;\n color: #ccc;\n}\n.breadcrumb > .active {\n color: #777777;\n}\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: 20px 0;\n border-radius: 4px;\n}\n.pagination > li {\n display: inline;\n}\n.pagination > li > a,\n.pagination > li > span {\n position: relative;\n float: left;\n padding: 6px 12px;\n line-height: 1.42857143;\n text-decoration: none;\n color: #337ab7;\n background-color: #fff;\n border: 1px solid #ddd;\n margin-left: -1px;\n}\n.pagination > li:first-child > a,\n.pagination > li:first-child > span {\n margin-left: 0;\n border-bottom-left-radius: 4px;\n border-top-left-radius: 4px;\n}\n.pagination > li:last-child > a,\n.pagination > li:last-child > span {\n border-bottom-right-radius: 4px;\n border-top-right-radius: 4px;\n}\n.pagination > li > a:hover,\n.pagination > li > span:hover,\n.pagination > li > a:focus,\n.pagination > li > span:focus {\n z-index: 2;\n color: #23527c;\n background-color: #eeeeee;\n border-color: #ddd;\n}\n.pagination > .active > a,\n.pagination > .active > span,\n.pagination > .active > a:hover,\n.pagination > .active > span:hover,\n.pagination > .active > a:focus,\n.pagination > .active > span:focus {\n z-index: 3;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n cursor: default;\n}\n.pagination > .disabled > span,\n.pagination > .disabled > span:hover,\n.pagination > .disabled > span:focus,\n.pagination > .disabled > a,\n.pagination > .disabled > a:hover,\n.pagination > .disabled > a:focus {\n color: #777777;\n background-color: #fff;\n border-color: #ddd;\n cursor: not-allowed;\n}\n.pagination-lg > li > a,\n.pagination-lg > li > span {\n padding: 10px 16px;\n font-size: 18px;\n line-height: 1.3333333;\n}\n.pagination-lg > li:first-child > a,\n.pagination-lg > li:first-child > span {\n border-bottom-left-radius: 6px;\n border-top-left-radius: 6px;\n}\n.pagination-lg > li:last-child > a,\n.pagination-lg > li:last-child > span {\n border-bottom-right-radius: 6px;\n border-top-right-radius: 6px;\n}\n.pagination-sm > li > a,\n.pagination-sm > li > span {\n padding: 5px 10px;\n font-size: 12px;\n line-height: 1.5;\n}\n.pagination-sm > li:first-child > a,\n.pagination-sm > li:first-child > span {\n border-bottom-left-radius: 3px;\n border-top-left-radius: 3px;\n}\n.pagination-sm > li:last-child > a,\n.pagination-sm > li:last-child > span {\n border-bottom-right-radius: 3px;\n border-top-right-radius: 3px;\n}\n.pager {\n padding-left: 0;\n margin: 20px 0;\n list-style: none;\n text-align: center;\n}\n.pager li {\n display: inline;\n}\n.pager li > a,\n.pager li > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 15px;\n}\n.pager li > a:hover,\n.pager li > a:focus {\n text-decoration: none;\n background-color: #eeeeee;\n}\n.pager .next > a,\n.pager .next > span {\n float: right;\n}\n.pager .previous > a,\n.pager .previous > span {\n float: left;\n}\n.pager .disabled > a,\n.pager .disabled > a:hover,\n.pager .disabled > a:focus,\n.pager .disabled > span {\n color: #777777;\n background-color: #fff;\n cursor: not-allowed;\n}\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: #fff;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n}\na.label:hover,\na.label:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.label:empty {\n display: none;\n}\n.btn .label {\n position: relative;\n top: -1px;\n}\n.label-default {\n background-color: #777777;\n}\n.label-default[href]:hover,\n.label-default[href]:focus {\n background-color: #5e5e5e;\n}\n.label-primary {\n background-color: #337ab7;\n}\n.label-primary[href]:hover,\n.label-primary[href]:focus {\n background-color: #286090;\n}\n.label-success {\n background-color: #5cb85c;\n}\n.label-success[href]:hover,\n.label-success[href]:focus {\n background-color: #449d44;\n}\n.label-info {\n background-color: #5bc0de;\n}\n.label-info[href]:hover,\n.label-info[href]:focus {\n background-color: #31b0d5;\n}\n.label-warning {\n background-color: #f0ad4e;\n}\n.label-warning[href]:hover,\n.label-warning[href]:focus {\n background-color: #ec971f;\n}\n.label-danger {\n background-color: #d9534f;\n}\n.label-danger[href]:hover,\n.label-danger[href]:focus {\n background-color: #c9302c;\n}\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: 12px;\n font-weight: bold;\n color: #fff;\n line-height: 1;\n vertical-align: middle;\n white-space: nowrap;\n text-align: center;\n background-color: #777777;\n border-radius: 10px;\n}\n.badge:empty {\n display: none;\n}\n.btn .badge {\n position: relative;\n top: -1px;\n}\n.btn-xs .badge,\n.btn-group-xs > .btn .badge {\n top: 0;\n padding: 1px 5px;\n}\na.badge:hover,\na.badge:focus {\n color: #fff;\n text-decoration: none;\n cursor: pointer;\n}\n.list-group-item.active > .badge,\n.nav-pills > .active > a > .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.list-group-item > .badge {\n float: right;\n}\n.list-group-item > .badge + .badge {\n margin-right: 5px;\n}\n.nav-pills > li > a > .badge {\n margin-left: 3px;\n}\n.jumbotron {\n padding-top: 30px;\n padding-bottom: 30px;\n margin-bottom: 30px;\n color: inherit;\n background-color: #eeeeee;\n}\n.jumbotron h1,\n.jumbotron .h1 {\n color: inherit;\n}\n.jumbotron p {\n margin-bottom: 15px;\n font-size: 21px;\n font-weight: 200;\n}\n.jumbotron > hr {\n border-top-color: #d5d5d5;\n}\n.container .jumbotron,\n.container-fluid .jumbotron {\n border-radius: 6px;\n padding-left: 15px;\n padding-right: 15px;\n}\n.jumbotron .container {\n max-width: 100%;\n}\n@media screen and (min-width: 768px) {\n .jumbotron {\n padding-top: 48px;\n padding-bottom: 48px;\n }\n .container .jumbotron,\n .container-fluid .jumbotron {\n padding-left: 60px;\n padding-right: 60px;\n }\n .jumbotron h1,\n .jumbotron .h1 {\n font-size: 63px;\n }\n}\n.thumbnail {\n display: block;\n padding: 4px;\n margin-bottom: 20px;\n line-height: 1.42857143;\n background-color: #fff;\n border: 1px solid #ddd;\n border-radius: 4px;\n -webkit-transition: border 0.2s ease-in-out;\n -o-transition: border 0.2s ease-in-out;\n transition: border 0.2s ease-in-out;\n}\n.thumbnail > img,\n.thumbnail a > img {\n margin-left: auto;\n margin-right: auto;\n}\na.thumbnail:hover,\na.thumbnail:focus,\na.thumbnail.active {\n border-color: #337ab7;\n}\n.thumbnail .caption {\n padding: 9px;\n color: #333333;\n}\n.alert {\n padding: 15px;\n margin-bottom: 20px;\n border: 1px solid transparent;\n border-radius: 4px;\n}\n.alert h4 {\n margin-top: 0;\n color: inherit;\n}\n.alert .alert-link {\n font-weight: bold;\n}\n.alert > p,\n.alert > ul {\n margin-bottom: 0;\n}\n.alert > p + p {\n margin-top: 5px;\n}\n.alert-dismissable,\n.alert-dismissible {\n padding-right: 35px;\n}\n.alert-dismissable .close,\n.alert-dismissible .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n}\n.alert-success {\n background-color: #dff0d8;\n border-color: #d6e9c6;\n color: #3c763d;\n}\n.alert-success hr {\n border-top-color: #c9e2b3;\n}\n.alert-success .alert-link {\n color: #2b542c;\n}\n.alert-info {\n background-color: #d9edf7;\n border-color: #bce8f1;\n color: #31708f;\n}\n.alert-info hr {\n border-top-color: #a6e1ec;\n}\n.alert-info .alert-link {\n color: #245269;\n}\n.alert-warning {\n background-color: #fcf8e3;\n border-color: #faebcc;\n color: #8a6d3b;\n}\n.alert-warning hr {\n border-top-color: #f7e1b5;\n}\n.alert-warning .alert-link {\n color: #66512c;\n}\n.alert-danger {\n background-color: #f2dede;\n border-color: #ebccd1;\n color: #a94442;\n}\n.alert-danger hr {\n border-top-color: #e4b9c0;\n}\n.alert-danger .alert-link {\n color: #843534;\n}\n@-webkit-keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n@keyframes progress-bar-stripes {\n from {\n background-position: 40px 0;\n }\n to {\n background-position: 0 0;\n }\n}\n.progress {\n overflow: hidden;\n height: 20px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);\n}\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: 12px;\n line-height: 20px;\n color: #fff;\n text-align: center;\n background-color: #337ab7;\n -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);\n -webkit-transition: width 0.6s ease;\n -o-transition: width 0.6s ease;\n transition: width 0.6s ease;\n}\n.progress-striped .progress-bar,\n.progress-bar-striped {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-size: 40px 40px;\n}\n.progress.active .progress-bar,\n.progress-bar.active {\n -webkit-animation: progress-bar-stripes 2s linear infinite;\n -o-animation: progress-bar-stripes 2s linear infinite;\n animation: progress-bar-stripes 2s linear infinite;\n}\n.progress-bar-success {\n background-color: #5cb85c;\n}\n.progress-striped .progress-bar-success {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-info {\n background-color: #5bc0de;\n}\n.progress-striped .progress-bar-info {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-warning {\n background-color: #f0ad4e;\n}\n.progress-striped .progress-bar-warning {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.progress-bar-danger {\n background-color: #d9534f;\n}\n.progress-striped .progress-bar-danger {\n background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent);\n}\n.media {\n margin-top: 15px;\n}\n.media:first-child {\n margin-top: 0;\n}\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n.media-body {\n width: 10000px;\n}\n.media-object {\n display: block;\n}\n.media-object.img-thumbnail {\n max-width: none;\n}\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n.media-middle {\n vertical-align: middle;\n}\n.media-bottom {\n vertical-align: bottom;\n}\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n.list-group {\n margin-bottom: 20px;\n padding-left: 0;\n}\n.list-group-item {\n position: relative;\n display: block;\n padding: 10px 15px;\n margin-bottom: -1px;\n background-color: #fff;\n border: 1px solid #ddd;\n}\n.list-group-item:first-child {\n border-top-right-radius: 4px;\n border-top-left-radius: 4px;\n}\n.list-group-item:last-child {\n margin-bottom: 0;\n border-bottom-right-radius: 4px;\n border-bottom-left-radius: 4px;\n}\na.list-group-item,\nbutton.list-group-item {\n color: #555;\n}\na.list-group-item .list-group-item-heading,\nbutton.list-group-item .list-group-item-heading {\n color: #333;\n}\na.list-group-item:hover,\nbutton.list-group-item:hover,\na.list-group-item:focus,\nbutton.list-group-item:focus {\n text-decoration: none;\n color: #555;\n background-color: #f5f5f5;\n}\nbutton.list-group-item {\n width: 100%;\n text-align: left;\n}\n.list-group-item.disabled,\n.list-group-item.disabled:hover,\n.list-group-item.disabled:focus {\n background-color: #eeeeee;\n color: #777777;\n cursor: not-allowed;\n}\n.list-group-item.disabled .list-group-item-heading,\n.list-group-item.disabled:hover .list-group-item-heading,\n.list-group-item.disabled:focus .list-group-item-heading {\n color: inherit;\n}\n.list-group-item.disabled .list-group-item-text,\n.list-group-item.disabled:hover .list-group-item-text,\n.list-group-item.disabled:focus .list-group-item-text {\n color: #777777;\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n z-index: 2;\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.list-group-item.active .list-group-item-heading,\n.list-group-item.active:hover .list-group-item-heading,\n.list-group-item.active:focus .list-group-item-heading,\n.list-group-item.active .list-group-item-heading > small,\n.list-group-item.active:hover .list-group-item-heading > small,\n.list-group-item.active:focus .list-group-item-heading > small,\n.list-group-item.active .list-group-item-heading > .small,\n.list-group-item.active:hover .list-group-item-heading > .small,\n.list-group-item.active:focus .list-group-item-heading > .small {\n color: inherit;\n}\n.list-group-item.active .list-group-item-text,\n.list-group-item.active:hover .list-group-item-text,\n.list-group-item.active:focus .list-group-item-text {\n color: #c7ddef;\n}\n.list-group-item-success {\n color: #3c763d;\n background-color: #dff0d8;\n}\na.list-group-item-success,\nbutton.list-group-item-success {\n color: #3c763d;\n}\na.list-group-item-success .list-group-item-heading,\nbutton.list-group-item-success .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-success:hover,\nbutton.list-group-item-success:hover,\na.list-group-item-success:focus,\nbutton.list-group-item-success:focus {\n color: #3c763d;\n background-color: #d0e9c6;\n}\na.list-group-item-success.active,\nbutton.list-group-item-success.active,\na.list-group-item-success.active:hover,\nbutton.list-group-item-success.active:hover,\na.list-group-item-success.active:focus,\nbutton.list-group-item-success.active:focus {\n color: #fff;\n background-color: #3c763d;\n border-color: #3c763d;\n}\n.list-group-item-info {\n color: #31708f;\n background-color: #d9edf7;\n}\na.list-group-item-info,\nbutton.list-group-item-info {\n color: #31708f;\n}\na.list-group-item-info .list-group-item-heading,\nbutton.list-group-item-info .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-info:hover,\nbutton.list-group-item-info:hover,\na.list-group-item-info:focus,\nbutton.list-group-item-info:focus {\n color: #31708f;\n background-color: #c4e3f3;\n}\na.list-group-item-info.active,\nbutton.list-group-item-info.active,\na.list-group-item-info.active:hover,\nbutton.list-group-item-info.active:hover,\na.list-group-item-info.active:focus,\nbutton.list-group-item-info.active:focus {\n color: #fff;\n background-color: #31708f;\n border-color: #31708f;\n}\n.list-group-item-warning {\n color: #8a6d3b;\n background-color: #fcf8e3;\n}\na.list-group-item-warning,\nbutton.list-group-item-warning {\n color: #8a6d3b;\n}\na.list-group-item-warning .list-group-item-heading,\nbutton.list-group-item-warning .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-warning:hover,\nbutton.list-group-item-warning:hover,\na.list-group-item-warning:focus,\nbutton.list-group-item-warning:focus {\n color: #8a6d3b;\n background-color: #faf2cc;\n}\na.list-group-item-warning.active,\nbutton.list-group-item-warning.active,\na.list-group-item-warning.active:hover,\nbutton.list-group-item-warning.active:hover,\na.list-group-item-warning.active:focus,\nbutton.list-group-item-warning.active:focus {\n color: #fff;\n background-color: #8a6d3b;\n border-color: #8a6d3b;\n}\n.list-group-item-danger {\n color: #a94442;\n background-color: #f2dede;\n}\na.list-group-item-danger,\nbutton.list-group-item-danger {\n color: #a94442;\n}\na.list-group-item-danger .list-group-item-heading,\nbutton.list-group-item-danger .list-group-item-heading {\n color: inherit;\n}\na.list-group-item-danger:hover,\nbutton.list-group-item-danger:hover,\na.list-group-item-danger:focus,\nbutton.list-group-item-danger:focus {\n color: #a94442;\n background-color: #ebcccc;\n}\na.list-group-item-danger.active,\nbutton.list-group-item-danger.active,\na.list-group-item-danger.active:hover,\nbutton.list-group-item-danger.active:hover,\na.list-group-item-danger.active:focus,\nbutton.list-group-item-danger.active:focus {\n color: #fff;\n background-color: #a94442;\n border-color: #a94442;\n}\n.list-group-item-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n.list-group-item-text {\n margin-bottom: 0;\n line-height: 1.3;\n}\n.panel {\n margin-bottom: 20px;\n background-color: #fff;\n border: 1px solid transparent;\n border-radius: 4px;\n -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.panel-body {\n padding: 15px;\n}\n.panel-heading {\n padding: 10px 15px;\n border-bottom: 1px solid transparent;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel-heading > .dropdown .dropdown-toggle {\n color: inherit;\n}\n.panel-title {\n margin-top: 0;\n margin-bottom: 0;\n font-size: 16px;\n color: inherit;\n}\n.panel-title > a,\n.panel-title > small,\n.panel-title > .small,\n.panel-title > small > a,\n.panel-title > .small > a {\n color: inherit;\n}\n.panel-footer {\n padding: 10px 15px;\n background-color: #f5f5f5;\n border-top: 1px solid #ddd;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .list-group,\n.panel > .panel-collapse > .list-group {\n margin-bottom: 0;\n}\n.panel > .list-group .list-group-item,\n.panel > .panel-collapse > .list-group .list-group-item {\n border-width: 1px 0;\n border-radius: 0;\n}\n.panel > .list-group:first-child .list-group-item:first-child,\n.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child {\n border-top: 0;\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .list-group:last-child .list-group-item:last-child,\n.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child {\n border-bottom: 0;\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .panel-heading + .panel-collapse > .list-group .list-group-item:first-child {\n border-top-right-radius: 0;\n border-top-left-radius: 0;\n}\n.panel-heading + .list-group .list-group-item:first-child {\n border-top-width: 0;\n}\n.list-group + .panel-footer {\n border-top-width: 0;\n}\n.panel > .table,\n.panel > .table-responsive > .table,\n.panel > .panel-collapse > .table {\n margin-bottom: 0;\n}\n.panel > .table caption,\n.panel > .table-responsive > .table caption,\n.panel > .panel-collapse > .table caption {\n padding-left: 15px;\n padding-right: 15px;\n}\n.panel > .table:first-child,\n.panel > .table-responsive:first-child > .table:first-child {\n border-top-right-radius: 3px;\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child {\n border-top-left-radius: 3px;\n border-top-right-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child {\n border-top-left-radius: 3px;\n}\n.panel > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child,\n.panel > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child,\n.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child,\n.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child {\n border-top-right-radius: 3px;\n}\n.panel > .table:last-child,\n.panel > .table-responsive:last-child > .table:last-child {\n border-bottom-right-radius: 3px;\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child {\n border-bottom-left-radius: 3px;\n border-bottom-right-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child {\n border-bottom-left-radius: 3px;\n}\n.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child,\n.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child,\n.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child,\n.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child {\n border-bottom-right-radius: 3px;\n}\n.panel > .panel-body + .table,\n.panel > .panel-body + .table-responsive,\n.panel > .table + .panel-body,\n.panel > .table-responsive + .panel-body {\n border-top: 1px solid #ddd;\n}\n.panel > .table > tbody:first-child > tr:first-child th,\n.panel > .table > tbody:first-child > tr:first-child td {\n border-top: 0;\n}\n.panel > .table-bordered,\n.panel > .table-responsive > .table-bordered {\n border: 0;\n}\n.panel > .table-bordered > thead > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:first-child,\n.panel > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child,\n.panel > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child,\n.panel > .table-bordered > thead > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:first-child,\n.panel > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child,\n.panel > .table-bordered > tfoot > tr > td:first-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child {\n border-left: 0;\n}\n.panel > .table-bordered > thead > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > th:last-child,\n.panel > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child,\n.panel > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child,\n.panel > .table-bordered > thead > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > thead > tr > td:last-child,\n.panel > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child,\n.panel > .table-bordered > tfoot > tr > td:last-child,\n.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child {\n border-right: 0;\n}\n.panel > .table-bordered > thead > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > td,\n.panel > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td,\n.panel > .table-bordered > thead > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > thead > tr:first-child > th,\n.panel > .table-bordered > tbody > tr:first-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th {\n border-bottom: 0;\n}\n.panel > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td,\n.panel > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td,\n.panel > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th,\n.panel > .table-bordered > tfoot > tr:last-child > th,\n.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th {\n border-bottom: 0;\n}\n.panel > .table-responsive {\n border: 0;\n margin-bottom: 0;\n}\n.panel-group {\n margin-bottom: 20px;\n}\n.panel-group .panel {\n margin-bottom: 0;\n border-radius: 4px;\n}\n.panel-group .panel + .panel {\n margin-top: 5px;\n}\n.panel-group .panel-heading {\n border-bottom: 0;\n}\n.panel-group .panel-heading + .panel-collapse > .panel-body,\n.panel-group .panel-heading + .panel-collapse > .list-group {\n border-top: 1px solid #ddd;\n}\n.panel-group .panel-footer {\n border-top: 0;\n}\n.panel-group .panel-footer + .panel-collapse .panel-body {\n border-bottom: 1px solid #ddd;\n}\n.panel-default {\n border-color: #ddd;\n}\n.panel-default > .panel-heading {\n color: #333333;\n background-color: #f5f5f5;\n border-color: #ddd;\n}\n.panel-default > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ddd;\n}\n.panel-default > .panel-heading .badge {\n color: #f5f5f5;\n background-color: #333333;\n}\n.panel-default > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ddd;\n}\n.panel-primary {\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading {\n color: #fff;\n background-color: #337ab7;\n border-color: #337ab7;\n}\n.panel-primary > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #337ab7;\n}\n.panel-primary > .panel-heading .badge {\n color: #337ab7;\n background-color: #fff;\n}\n.panel-primary > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #337ab7;\n}\n.panel-success {\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading {\n color: #3c763d;\n background-color: #dff0d8;\n border-color: #d6e9c6;\n}\n.panel-success > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #d6e9c6;\n}\n.panel-success > .panel-heading .badge {\n color: #dff0d8;\n background-color: #3c763d;\n}\n.panel-success > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #d6e9c6;\n}\n.panel-info {\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading {\n color: #31708f;\n background-color: #d9edf7;\n border-color: #bce8f1;\n}\n.panel-info > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #bce8f1;\n}\n.panel-info > .panel-heading .badge {\n color: #d9edf7;\n background-color: #31708f;\n}\n.panel-info > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #bce8f1;\n}\n.panel-warning {\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading {\n color: #8a6d3b;\n background-color: #fcf8e3;\n border-color: #faebcc;\n}\n.panel-warning > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #faebcc;\n}\n.panel-warning > .panel-heading .badge {\n color: #fcf8e3;\n background-color: #8a6d3b;\n}\n.panel-warning > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #faebcc;\n}\n.panel-danger {\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading {\n color: #a94442;\n background-color: #f2dede;\n border-color: #ebccd1;\n}\n.panel-danger > .panel-heading + .panel-collapse > .panel-body {\n border-top-color: #ebccd1;\n}\n.panel-danger > .panel-heading .badge {\n color: #f2dede;\n background-color: #a94442;\n}\n.panel-danger > .panel-footer + .panel-collapse > .panel-body {\n border-bottom-color: #ebccd1;\n}\n.embed-responsive {\n position: relative;\n display: block;\n height: 0;\n padding: 0;\n overflow: hidden;\n}\n.embed-responsive .embed-responsive-item,\n.embed-responsive iframe,\n.embed-responsive embed,\n.embed-responsive object,\n.embed-responsive video {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n height: 100%;\n width: 100%;\n border: 0;\n}\n.embed-responsive-16by9 {\n padding-bottom: 56.25%;\n}\n.embed-responsive-4by3 {\n padding-bottom: 75%;\n}\n.well {\n min-height: 20px;\n padding: 19px;\n margin-bottom: 20px;\n background-color: #f5f5f5;\n border: 1px solid #e3e3e3;\n border-radius: 4px;\n -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);\n}\n.well blockquote {\n border-color: #ddd;\n border-color: rgba(0, 0, 0, 0.15);\n}\n.well-lg {\n padding: 24px;\n border-radius: 6px;\n}\n.well-sm {\n padding: 9px;\n border-radius: 3px;\n}\n.close {\n float: right;\n font-size: 21px;\n font-weight: bold;\n line-height: 1;\n color: #000;\n text-shadow: 0 1px 0 #fff;\n opacity: 0.2;\n filter: alpha(opacity=20);\n}\n.close:hover,\n.close:focus {\n color: #000;\n text-decoration: none;\n cursor: pointer;\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\nbutton.close {\n padding: 0;\n cursor: pointer;\n background: transparent;\n border: 0;\n -webkit-appearance: none;\n}\n.modal-open {\n overflow: hidden;\n}\n.modal {\n display: none;\n overflow: hidden;\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1050;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.modal.fade .modal-dialog {\n -webkit-transform: translate(0, -25%);\n -ms-transform: translate(0, -25%);\n -o-transform: translate(0, -25%);\n transform: translate(0, -25%);\n -webkit-transition: -webkit-transform 0.3s ease-out;\n -moz-transition: -moz-transform 0.3s ease-out;\n -o-transition: -o-transform 0.3s ease-out;\n transition: transform 0.3s ease-out;\n}\n.modal.in .modal-dialog {\n -webkit-transform: translate(0, 0);\n -ms-transform: translate(0, 0);\n -o-transform: translate(0, 0);\n transform: translate(0, 0);\n}\n.modal-open .modal {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.modal-dialog {\n position: relative;\n width: auto;\n margin: 10px;\n}\n.modal-content {\n position: relative;\n background-color: #fff;\n border: 1px solid #999;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n box-shadow: 0 3px 9px rgba(0, 0, 0, 0.5);\n background-clip: padding-box;\n outline: 0;\n}\n.modal-backdrop {\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1040;\n background-color: #000;\n}\n.modal-backdrop.fade {\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.modal-backdrop.in {\n opacity: 0.5;\n filter: alpha(opacity=50);\n}\n.modal-header {\n padding: 15px;\n border-bottom: 1px solid #e5e5e5;\n}\n.modal-header .close {\n margin-top: -2px;\n}\n.modal-title {\n margin: 0;\n line-height: 1.42857143;\n}\n.modal-body {\n position: relative;\n padding: 15px;\n}\n.modal-footer {\n padding: 15px;\n text-align: right;\n border-top: 1px solid #e5e5e5;\n}\n.modal-footer .btn + .btn {\n margin-left: 5px;\n margin-bottom: 0;\n}\n.modal-footer .btn-group .btn + .btn {\n margin-left: -1px;\n}\n.modal-footer .btn-block + .btn-block {\n margin-left: 0;\n}\n.modal-scrollbar-measure {\n position: absolute;\n top: -9999px;\n width: 50px;\n height: 50px;\n overflow: scroll;\n}\n@media (min-width: 768px) {\n .modal-dialog {\n width: 600px;\n margin: 30px auto;\n }\n .modal-content {\n -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);\n }\n .modal-sm {\n width: 300px;\n }\n}\n@media (min-width: 992px) {\n .modal-lg {\n width: 900px;\n }\n}\n.tooltip {\n position: absolute;\n z-index: 1070;\n display: block;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n font-size: 12px;\n opacity: 0;\n filter: alpha(opacity=0);\n}\n.tooltip.in {\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.tooltip.top {\n margin-top: -3px;\n padding: 5px 0;\n}\n.tooltip.right {\n margin-left: 3px;\n padding: 0 5px;\n}\n.tooltip.bottom {\n margin-top: 3px;\n padding: 5px 0;\n}\n.tooltip.left {\n margin-left: -3px;\n padding: 0 5px;\n}\n.tooltip-inner {\n max-width: 200px;\n padding: 3px 8px;\n color: #fff;\n text-align: center;\n background-color: #000;\n border-radius: 4px;\n}\n.tooltip-arrow {\n position: absolute;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.tooltip.top .tooltip-arrow {\n bottom: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-left .tooltip-arrow {\n bottom: 0;\n right: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.top-right .tooltip-arrow {\n bottom: 0;\n left: 5px;\n margin-bottom: -5px;\n border-width: 5px 5px 0;\n border-top-color: #000;\n}\n.tooltip.right .tooltip-arrow {\n top: 50%;\n left: 0;\n margin-top: -5px;\n border-width: 5px 5px 5px 0;\n border-right-color: #000;\n}\n.tooltip.left .tooltip-arrow {\n top: 50%;\n right: 0;\n margin-top: -5px;\n border-width: 5px 0 5px 5px;\n border-left-color: #000;\n}\n.tooltip.bottom .tooltip-arrow {\n top: 0;\n left: 50%;\n margin-left: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-left .tooltip-arrow {\n top: 0;\n right: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.tooltip.bottom-right .tooltip-arrow {\n top: 0;\n left: 5px;\n margin-top: -5px;\n border-width: 0 5px 5px;\n border-bottom-color: #000;\n}\n.popover {\n position: absolute;\n top: 0;\n left: 0;\n z-index: 1060;\n display: none;\n max-width: 276px;\n padding: 1px;\n font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-style: normal;\n font-weight: normal;\n letter-spacing: normal;\n line-break: auto;\n line-height: 1.42857143;\n text-align: left;\n text-align: start;\n text-decoration: none;\n text-shadow: none;\n text-transform: none;\n white-space: normal;\n word-break: normal;\n word-spacing: normal;\n word-wrap: normal;\n font-size: 14px;\n background-color: #fff;\n background-clip: padding-box;\n border: 1px solid #ccc;\n border: 1px solid rgba(0, 0, 0, 0.2);\n border-radius: 6px;\n -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);\n}\n.popover.top {\n margin-top: -10px;\n}\n.popover.right {\n margin-left: 10px;\n}\n.popover.bottom {\n margin-top: 10px;\n}\n.popover.left {\n margin-left: -10px;\n}\n.popover-title {\n margin: 0;\n padding: 8px 14px;\n font-size: 14px;\n background-color: #f7f7f7;\n border-bottom: 1px solid #ebebeb;\n border-radius: 5px 5px 0 0;\n}\n.popover-content {\n padding: 9px 14px;\n}\n.popover > .arrow,\n.popover > .arrow:after {\n position: absolute;\n display: block;\n width: 0;\n height: 0;\n border-color: transparent;\n border-style: solid;\n}\n.popover > .arrow {\n border-width: 11px;\n}\n.popover > .arrow:after {\n border-width: 10px;\n content: \"\";\n}\n.popover.top > .arrow {\n left: 50%;\n margin-left: -11px;\n border-bottom-width: 0;\n border-top-color: #999999;\n border-top-color: rgba(0, 0, 0, 0.25);\n bottom: -11px;\n}\n.popover.top > .arrow:after {\n content: \" \";\n bottom: 1px;\n margin-left: -10px;\n border-bottom-width: 0;\n border-top-color: #fff;\n}\n.popover.right > .arrow {\n top: 50%;\n left: -11px;\n margin-top: -11px;\n border-left-width: 0;\n border-right-color: #999999;\n border-right-color: rgba(0, 0, 0, 0.25);\n}\n.popover.right > .arrow:after {\n content: \" \";\n left: 1px;\n bottom: -10px;\n border-left-width: 0;\n border-right-color: #fff;\n}\n.popover.bottom > .arrow {\n left: 50%;\n margin-left: -11px;\n border-top-width: 0;\n border-bottom-color: #999999;\n border-bottom-color: rgba(0, 0, 0, 0.25);\n top: -11px;\n}\n.popover.bottom > .arrow:after {\n content: \" \";\n top: 1px;\n margin-left: -10px;\n border-top-width: 0;\n border-bottom-color: #fff;\n}\n.popover.left > .arrow {\n top: 50%;\n right: -11px;\n margin-top: -11px;\n border-right-width: 0;\n border-left-color: #999999;\n border-left-color: rgba(0, 0, 0, 0.25);\n}\n.popover.left > .arrow:after {\n content: \" \";\n right: 1px;\n border-right-width: 0;\n border-left-color: #fff;\n bottom: -10px;\n}\n.carousel {\n position: relative;\n}\n.carousel-inner {\n position: relative;\n overflow: hidden;\n width: 100%;\n}\n.carousel-inner > .item {\n display: none;\n position: relative;\n -webkit-transition: 0.6s ease-in-out left;\n -o-transition: 0.6s ease-in-out left;\n transition: 0.6s ease-in-out left;\n}\n.carousel-inner > .item > img,\n.carousel-inner > .item > a > img {\n line-height: 1;\n}\n@media all and (transform-3d), (-webkit-transform-3d) {\n .carousel-inner > .item {\n -webkit-transition: -webkit-transform 0.6s ease-in-out;\n -moz-transition: -moz-transform 0.6s ease-in-out;\n -o-transition: -o-transform 0.6s ease-in-out;\n transition: transform 0.6s ease-in-out;\n -webkit-backface-visibility: hidden;\n -moz-backface-visibility: hidden;\n backface-visibility: hidden;\n -webkit-perspective: 1000px;\n -moz-perspective: 1000px;\n perspective: 1000px;\n }\n .carousel-inner > .item.next,\n .carousel-inner > .item.active.right {\n -webkit-transform: translate3d(100%, 0, 0);\n transform: translate3d(100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.prev,\n .carousel-inner > .item.active.left {\n -webkit-transform: translate3d(-100%, 0, 0);\n transform: translate3d(-100%, 0, 0);\n left: 0;\n }\n .carousel-inner > .item.next.left,\n .carousel-inner > .item.prev.right,\n .carousel-inner > .item.active {\n -webkit-transform: translate3d(0, 0, 0);\n transform: translate3d(0, 0, 0);\n left: 0;\n }\n}\n.carousel-inner > .active,\n.carousel-inner > .next,\n.carousel-inner > .prev {\n display: block;\n}\n.carousel-inner > .active {\n left: 0;\n}\n.carousel-inner > .next,\n.carousel-inner > .prev {\n position: absolute;\n top: 0;\n width: 100%;\n}\n.carousel-inner > .next {\n left: 100%;\n}\n.carousel-inner > .prev {\n left: -100%;\n}\n.carousel-inner > .next.left,\n.carousel-inner > .prev.right {\n left: 0;\n}\n.carousel-inner > .active.left {\n left: -100%;\n}\n.carousel-inner > .active.right {\n left: 100%;\n}\n.carousel-control {\n position: absolute;\n top: 0;\n left: 0;\n bottom: 0;\n width: 15%;\n opacity: 0.5;\n filter: alpha(opacity=50);\n font-size: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-control.left {\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.0001) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);\n}\n.carousel-control.right {\n left: auto;\n right: 0;\n background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-image: linear-gradient(to right, rgba(0, 0, 0, 0.0001) 0%, rgba(0, 0, 0, 0.5) 100%);\n background-repeat: repeat-x;\n filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);\n}\n.carousel-control:hover,\n.carousel-control:focus {\n outline: 0;\n color: #fff;\n text-decoration: none;\n opacity: 0.9;\n filter: alpha(opacity=90);\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-left,\n.carousel-control .glyphicon-chevron-right {\n position: absolute;\n top: 50%;\n margin-top: -10px;\n z-index: 5;\n display: inline-block;\n}\n.carousel-control .icon-prev,\n.carousel-control .glyphicon-chevron-left {\n left: 50%;\n margin-left: -10px;\n}\n.carousel-control .icon-next,\n.carousel-control .glyphicon-chevron-right {\n right: 50%;\n margin-right: -10px;\n}\n.carousel-control .icon-prev,\n.carousel-control .icon-next {\n width: 20px;\n height: 20px;\n line-height: 1;\n font-family: serif;\n}\n.carousel-control .icon-prev:before {\n content: '\\2039';\n}\n.carousel-control .icon-next:before {\n content: '\\203a';\n}\n.carousel-indicators {\n position: absolute;\n bottom: 10px;\n left: 50%;\n z-index: 15;\n width: 60%;\n margin-left: -30%;\n padding-left: 0;\n list-style: none;\n text-align: center;\n}\n.carousel-indicators li {\n display: inline-block;\n width: 10px;\n height: 10px;\n margin: 1px;\n text-indent: -999px;\n border: 1px solid #fff;\n border-radius: 10px;\n cursor: pointer;\n background-color: #000 \\9;\n background-color: rgba(0, 0, 0, 0);\n}\n.carousel-indicators .active {\n margin: 0;\n width: 12px;\n height: 12px;\n background-color: #fff;\n}\n.carousel-caption {\n position: absolute;\n left: 15%;\n right: 15%;\n bottom: 20px;\n z-index: 10;\n padding-top: 20px;\n padding-bottom: 20px;\n color: #fff;\n text-align: center;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n}\n.carousel-caption .btn {\n text-shadow: none;\n}\n@media screen and (min-width: 768px) {\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-prev,\n .carousel-control .icon-next {\n width: 30px;\n height: 30px;\n margin-top: -10px;\n font-size: 30px;\n }\n .carousel-control .glyphicon-chevron-left,\n .carousel-control .icon-prev {\n margin-left: -10px;\n }\n .carousel-control .glyphicon-chevron-right,\n .carousel-control .icon-next {\n margin-right: -10px;\n }\n .carousel-caption {\n left: 20%;\n right: 20%;\n padding-bottom: 30px;\n }\n .carousel-indicators {\n bottom: 20px;\n }\n}\n.clearfix:before,\n.clearfix:after,\n.dl-horizontal dd:before,\n.dl-horizontal dd:after,\n.container:before,\n.container:after,\n.container-fluid:before,\n.container-fluid:after,\n.row:before,\n.row:after,\n.form-horizontal .form-group:before,\n.form-horizontal .form-group:after,\n.btn-toolbar:before,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:before,\n.btn-group-vertical > .btn-group:after,\n.nav:before,\n.nav:after,\n.navbar:before,\n.navbar:after,\n.navbar-header:before,\n.navbar-header:after,\n.navbar-collapse:before,\n.navbar-collapse:after,\n.pager:before,\n.pager:after,\n.panel-body:before,\n.panel-body:after,\n.modal-header:before,\n.modal-header:after,\n.modal-footer:before,\n.modal-footer:after {\n content: \" \";\n display: table;\n}\n.clearfix:after,\n.dl-horizontal dd:after,\n.container:after,\n.container-fluid:after,\n.row:after,\n.form-horizontal .form-group:after,\n.btn-toolbar:after,\n.btn-group-vertical > .btn-group:after,\n.nav:after,\n.navbar:after,\n.navbar-header:after,\n.navbar-collapse:after,\n.pager:after,\n.panel-body:after,\n.modal-header:after,\n.modal-footer:after {\n clear: both;\n}\n.center-block {\n display: block;\n margin-left: auto;\n margin-right: auto;\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n font: 0/0 a;\n color: transparent;\n text-shadow: none;\n background-color: transparent;\n border: 0;\n}\n.hidden {\n display: none !important;\n}\n.affix {\n position: fixed;\n}\n@-ms-viewport {\n width: device-width;\n}\n.visible-xs,\n.visible-sm,\n.visible-md,\n.visible-lg {\n display: none !important;\n}\n.visible-xs-block,\n.visible-xs-inline,\n.visible-xs-inline-block,\n.visible-sm-block,\n.visible-sm-inline,\n.visible-sm-inline-block,\n.visible-md-block,\n.visible-md-inline,\n.visible-md-inline-block,\n.visible-lg-block,\n.visible-lg-inline,\n.visible-lg-inline-block {\n display: none !important;\n}\n@media (max-width: 767px) {\n .visible-xs {\n display: block !important;\n }\n table.visible-xs {\n display: table !important;\n }\n tr.visible-xs {\n display: table-row !important;\n }\n th.visible-xs,\n td.visible-xs {\n display: table-cell !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-block {\n display: block !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline {\n display: inline !important;\n }\n}\n@media (max-width: 767px) {\n .visible-xs-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm {\n display: block !important;\n }\n table.visible-sm {\n display: table !important;\n }\n tr.visible-sm {\n display: table-row !important;\n }\n th.visible-sm,\n td.visible-sm {\n display: table-cell !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-block {\n display: block !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline {\n display: inline !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .visible-sm-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md {\n display: block !important;\n }\n table.visible-md {\n display: table !important;\n }\n tr.visible-md {\n display: table-row !important;\n }\n th.visible-md,\n td.visible-md {\n display: table-cell !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-block {\n display: block !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline {\n display: inline !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .visible-md-inline-block {\n display: inline-block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg {\n display: block !important;\n }\n table.visible-lg {\n display: table !important;\n }\n tr.visible-lg {\n display: table-row !important;\n }\n th.visible-lg,\n td.visible-lg {\n display: table-cell !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-block {\n display: block !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline {\n display: inline !important;\n }\n}\n@media (min-width: 1200px) {\n .visible-lg-inline-block {\n display: inline-block !important;\n }\n}\n@media (max-width: 767px) {\n .hidden-xs {\n display: none !important;\n }\n}\n@media (min-width: 768px) and (max-width: 991px) {\n .hidden-sm {\n display: none !important;\n }\n}\n@media (min-width: 992px) and (max-width: 1199px) {\n .hidden-md {\n display: none !important;\n }\n}\n@media (min-width: 1200px) {\n .hidden-lg {\n display: none !important;\n }\n}\n.visible-print {\n display: none !important;\n}\n@media print {\n .visible-print {\n display: block !important;\n }\n table.visible-print {\n display: table !important;\n }\n tr.visible-print {\n display: table-row !important;\n }\n th.visible-print,\n td.visible-print {\n display: table-cell !important;\n }\n}\n.visible-print-block {\n display: none !important;\n}\n@media print {\n .visible-print-block {\n display: block !important;\n }\n}\n.visible-print-inline {\n display: none !important;\n}\n@media print {\n .visible-print-inline {\n display: inline !important;\n }\n}\n.visible-print-inline-block {\n display: none !important;\n}\n@media print {\n .visible-print-inline-block {\n display: inline-block !important;\n }\n}\n@media print {\n .hidden-print {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap.css.map */","/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */\n\n//\n// 1. Set default font family to sans-serif.\n// 2. Prevent iOS and IE text size adjust after device orientation change,\n// without disabling user zoom.\n//\n\nhtml {\n font-family: sans-serif; // 1\n -ms-text-size-adjust: 100%; // 2\n -webkit-text-size-adjust: 100%; // 2\n}\n\n//\n// Remove default margin.\n//\n\nbody {\n margin: 0;\n}\n\n// HTML5 display definitions\n// ==========================================================================\n\n//\n// Correct `block` display not defined for any HTML5 element in IE 8/9.\n// Correct `block` display not defined for `details` or `summary` in IE 10/11\n// and Firefox.\n// Correct `block` display not defined for `main` in IE 11.\n//\n\narticle,\naside,\ndetails,\nfigcaption,\nfigure,\nfooter,\nheader,\nhgroup,\nmain,\nmenu,\nnav,\nsection,\nsummary {\n display: block;\n}\n\n//\n// 1. Correct `inline-block` display not defined in IE 8/9.\n// 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.\n//\n\naudio,\ncanvas,\nprogress,\nvideo {\n display: inline-block; // 1\n vertical-align: baseline; // 2\n}\n\n//\n// Prevent modern browsers from displaying `audio` without controls.\n// Remove excess height in iOS 5 devices.\n//\n\naudio:not([controls]) {\n display: none;\n height: 0;\n}\n\n//\n// Address `[hidden]` styling not present in IE 8/9/10.\n// Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22.\n//\n\n[hidden],\ntemplate {\n display: none;\n}\n\n// Links\n// ==========================================================================\n\n//\n// Remove the gray background color from active links in IE 10.\n//\n\na {\n background-color: transparent;\n}\n\n//\n// Improve readability of focused elements when they are also in an\n// active/hover state.\n//\n\na:active,\na:hover {\n outline: 0;\n}\n\n// Text-level semantics\n// ==========================================================================\n\n//\n// Address styling not present in IE 8/9/10/11, Safari, and Chrome.\n//\n\nabbr[title] {\n border-bottom: 1px dotted;\n}\n\n//\n// Address style set to `bolder` in Firefox 4+, Safari, and Chrome.\n//\n\nb,\nstrong {\n font-weight: bold;\n}\n\n//\n// Address styling not present in Safari and Chrome.\n//\n\ndfn {\n font-style: italic;\n}\n\n//\n// Address variable `h1` font-size and margin within `section` and `article`\n// contexts in Firefox 4+, Safari, and Chrome.\n//\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n//\n// Address styling not present in IE 8/9.\n//\n\nmark {\n background: #ff0;\n color: #000;\n}\n\n//\n// Address inconsistent and variable font size in all browsers.\n//\n\nsmall {\n font-size: 80%;\n}\n\n//\n// Prevent `sub` and `sup` affecting `line-height` in all browsers.\n//\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsup {\n top: -0.5em;\n}\n\nsub {\n bottom: -0.25em;\n}\n\n// Embedded content\n// ==========================================================================\n\n//\n// Remove border when inside `a` element in IE 8/9/10.\n//\n\nimg {\n border: 0;\n}\n\n//\n// Correct overflow not hidden in IE 9/10/11.\n//\n\nsvg:not(:root) {\n overflow: hidden;\n}\n\n// Grouping content\n// ==========================================================================\n\n//\n// Address margin not present in IE 8/9 and Safari.\n//\n\nfigure {\n margin: 1em 40px;\n}\n\n//\n// Address differences between Firefox and other browsers.\n//\n\nhr {\n box-sizing: content-box;\n height: 0;\n}\n\n//\n// Contain overflow in all browsers.\n//\n\npre {\n overflow: auto;\n}\n\n//\n// Address odd `em`-unit font size rendering in all browsers.\n//\n\ncode,\nkbd,\npre,\nsamp {\n font-family: monospace, monospace;\n font-size: 1em;\n}\n\n// Forms\n// ==========================================================================\n\n//\n// Known limitation: by default, Chrome and Safari on OS X allow very limited\n// styling of `select`, unless a `border` property is set.\n//\n\n//\n// 1. Correct color not being inherited.\n// Known issue: affects color of disabled elements.\n// 2. Correct font properties not being inherited.\n// 3. Address margins set differently in Firefox 4+, Safari, and Chrome.\n//\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n color: inherit; // 1\n font: inherit; // 2\n margin: 0; // 3\n}\n\n//\n// Address `overflow` set to `hidden` in IE 8/9/10/11.\n//\n\nbutton {\n overflow: visible;\n}\n\n//\n// Address inconsistent `text-transform` inheritance for `button` and `select`.\n// All other form control elements do not inherit `text-transform` values.\n// Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.\n// Correct `select` style inheritance in Firefox.\n//\n\nbutton,\nselect {\n text-transform: none;\n}\n\n//\n// 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`\n// and `video` controls.\n// 2. Correct inability to style clickable `input` types in iOS.\n// 3. Improve usability and consistency of cursor style between image-type\n// `input` and others.\n//\n\nbutton,\nhtml input[type=\"button\"], // 1\ninput[type=\"reset\"],\ninput[type=\"submit\"] {\n -webkit-appearance: button; // 2\n cursor: pointer; // 3\n}\n\n//\n// Re-set default cursor for disabled elements.\n//\n\nbutton[disabled],\nhtml input[disabled] {\n cursor: default;\n}\n\n//\n// Remove inner padding and border in Firefox 4+.\n//\n\nbutton::-moz-focus-inner,\ninput::-moz-focus-inner {\n border: 0;\n padding: 0;\n}\n\n//\n// Address Firefox 4+ setting `line-height` on `input` using `!important` in\n// the UA stylesheet.\n//\n\ninput {\n line-height: normal;\n}\n\n//\n// It's recommended that you don't attempt to style these elements.\n// Firefox's implementation doesn't respect box-sizing, padding, or width.\n//\n// 1. Address box sizing set to `content-box` in IE 8/9/10.\n// 2. Remove excess padding in IE 8/9/10.\n//\n\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n box-sizing: border-box; // 1\n padding: 0; // 2\n}\n\n//\n// Fix the cursor style for Chrome's increment/decrement buttons. For certain\n// `font-size` values of the `input`, it causes the cursor style of the\n// decrement button to change from `default` to `text`.\n//\n\ninput[type=\"number\"]::-webkit-inner-spin-button,\ninput[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n//\n// 1. Address `appearance` set to `searchfield` in Safari and Chrome.\n// 2. Address `box-sizing` set to `border-box` in Safari and Chrome.\n//\n\ninput[type=\"search\"] {\n -webkit-appearance: textfield; // 1\n box-sizing: content-box; //2\n}\n\n//\n// Remove inner padding and search cancel button in Safari and Chrome on OS X.\n// Safari (but not Chrome) clips the cancel button when the search input has\n// padding (and `textfield` appearance).\n//\n\ninput[type=\"search\"]::-webkit-search-cancel-button,\ninput[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n//\n// Define consistent border, margin, and padding.\n//\n\nfieldset {\n border: 1px solid #c0c0c0;\n margin: 0 2px;\n padding: 0.35em 0.625em 0.75em;\n}\n\n//\n// 1. Correct `color` not being inherited in IE 8/9/10/11.\n// 2. Remove padding so people aren't caught out if they zero out fieldsets.\n//\n\nlegend {\n border: 0; // 1\n padding: 0; // 2\n}\n\n//\n// Remove default vertical scrollbar in IE 8/9/10/11.\n//\n\ntextarea {\n overflow: auto;\n}\n\n//\n// Don't inherit the `font-weight` (applied by a rule above).\n// NOTE: the default cannot safely be changed in Chrome and Safari on OS X.\n//\n\noptgroup {\n font-weight: bold;\n}\n\n// Tables\n// ==========================================================================\n\n//\n// Remove most spacing between table cells.\n//\n\ntable {\n border-collapse: collapse;\n border-spacing: 0;\n}\n\ntd,\nth {\n padding: 0;\n}\n","/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */\n\n// ==========================================================================\n// Print styles.\n// Inlined to avoid the additional HTTP request: h5bp.com/r\n// ==========================================================================\n\n@media print {\n *,\n *:before,\n *:after {\n background: transparent !important;\n color: #000 !important; // Black prints faster: h5bp.com/s\n box-shadow: none !important;\n text-shadow: none !important;\n }\n\n a,\n a:visited {\n text-decoration: underline;\n }\n\n a[href]:after {\n content: \" (\" attr(href) \")\";\n }\n\n abbr[title]:after {\n content: \" (\" attr(title) \")\";\n }\n\n // Don't show links that are fragment identifiers,\n // or use the `javascript:` pseudo protocol\n a[href^=\"#\"]:after,\n a[href^=\"javascript:\"]:after {\n content: \"\";\n }\n\n pre,\n blockquote {\n border: 1px solid #999;\n page-break-inside: avoid;\n }\n\n thead {\n display: table-header-group; // h5bp.com/t\n }\n\n tr,\n img {\n page-break-inside: avoid;\n }\n\n img {\n max-width: 100% !important;\n }\n\n p,\n h2,\n h3 {\n orphans: 3;\n widows: 3;\n }\n\n h2,\n h3 {\n page-break-after: avoid;\n }\n\n // Bootstrap specific changes start\n\n // Bootstrap components\n .navbar {\n display: none;\n }\n .btn,\n .dropup > .btn {\n > .caret {\n border-top-color: #000 !important;\n }\n }\n .label {\n border: 1px solid #000;\n }\n\n .table {\n border-collapse: collapse !important;\n\n td,\n th {\n background-color: #fff !important;\n }\n }\n .table-bordered {\n th,\n td {\n border: 1px solid #ddd !important;\n }\n }\n\n // Bootstrap specific changes end\n}\n","//\n// Glyphicons for Bootstrap\n//\n// Since icons are fonts, they can be placed anywhere text is placed and are\n// thus automatically sized to match the surrounding child. To use, create an\n// inline element with the appropriate classes, like so:\n//\n// Star\n\n// Import the fonts\n@font-face {\n font-family: 'Glyphicons Halflings';\n src: url('@{icon-font-path}@{icon-font-name}.eot');\n src: url('@{icon-font-path}@{icon-font-name}.eot?#iefix') format('embedded-opentype'),\n url('@{icon-font-path}@{icon-font-name}.woff2') format('woff2'),\n url('@{icon-font-path}@{icon-font-name}.woff') format('woff'),\n url('@{icon-font-path}@{icon-font-name}.ttf') format('truetype'),\n url('@{icon-font-path}@{icon-font-name}.svg#@{icon-font-svg-id}') format('svg');\n}\n\n// Catchall baseclass\n.glyphicon {\n position: relative;\n top: 1px;\n display: inline-block;\n font-family: 'Glyphicons Halflings';\n font-style: normal;\n font-weight: normal;\n line-height: 1;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\n// Individual icons\n.glyphicon-asterisk { &:before { content: \"\\002a\"; } }\n.glyphicon-plus { &:before { content: \"\\002b\"; } }\n.glyphicon-euro,\n.glyphicon-eur { &:before { content: \"\\20ac\"; } }\n.glyphicon-minus { &:before { content: \"\\2212\"; } }\n.glyphicon-cloud { &:before { content: \"\\2601\"; } }\n.glyphicon-envelope { &:before { content: \"\\2709\"; } }\n.glyphicon-pencil { &:before { content: \"\\270f\"; } }\n.glyphicon-glass { &:before { content: \"\\e001\"; } }\n.glyphicon-music { &:before { content: \"\\e002\"; } }\n.glyphicon-search { &:before { content: \"\\e003\"; } }\n.glyphicon-heart { &:before { content: \"\\e005\"; } }\n.glyphicon-star { &:before { content: \"\\e006\"; } }\n.glyphicon-star-empty { &:before { content: \"\\e007\"; } }\n.glyphicon-user { &:before { content: \"\\e008\"; } }\n.glyphicon-film { &:before { content: \"\\e009\"; } }\n.glyphicon-th-large { &:before { content: \"\\e010\"; } }\n.glyphicon-th { &:before { content: \"\\e011\"; } }\n.glyphicon-th-list { &:before { content: \"\\e012\"; } }\n.glyphicon-ok { &:before { content: \"\\e013\"; } }\n.glyphicon-remove { &:before { content: \"\\e014\"; } }\n.glyphicon-zoom-in { &:before { content: \"\\e015\"; } }\n.glyphicon-zoom-out { &:before { content: \"\\e016\"; } }\n.glyphicon-off { &:before { content: \"\\e017\"; } }\n.glyphicon-signal { &:before { content: \"\\e018\"; } }\n.glyphicon-cog { &:before { content: \"\\e019\"; } }\n.glyphicon-trash { &:before { content: \"\\e020\"; } }\n.glyphicon-home { &:before { content: \"\\e021\"; } }\n.glyphicon-file { &:before { content: \"\\e022\"; } }\n.glyphicon-time { &:before { content: \"\\e023\"; } }\n.glyphicon-road { &:before { content: \"\\e024\"; } }\n.glyphicon-download-alt { &:before { content: \"\\e025\"; } }\n.glyphicon-download { &:before { content: \"\\e026\"; } }\n.glyphicon-upload { &:before { content: \"\\e027\"; } }\n.glyphicon-inbox { &:before { content: \"\\e028\"; } }\n.glyphicon-play-circle { &:before { content: \"\\e029\"; } }\n.glyphicon-repeat { &:before { content: \"\\e030\"; } }\n.glyphicon-refresh { &:before { content: \"\\e031\"; } }\n.glyphicon-list-alt { &:before { content: \"\\e032\"; } }\n.glyphicon-lock { &:before { content: \"\\e033\"; } }\n.glyphicon-flag { &:before { content: \"\\e034\"; } }\n.glyphicon-headphones { &:before { content: \"\\e035\"; } }\n.glyphicon-volume-off { &:before { content: \"\\e036\"; } }\n.glyphicon-volume-down { &:before { content: \"\\e037\"; } }\n.glyphicon-volume-up { &:before { content: \"\\e038\"; } }\n.glyphicon-qrcode { &:before { content: \"\\e039\"; } }\n.glyphicon-barcode { &:before { content: \"\\e040\"; } }\n.glyphicon-tag { &:before { content: \"\\e041\"; } }\n.glyphicon-tags { &:before { content: \"\\e042\"; } }\n.glyphicon-book { &:before { content: \"\\e043\"; } }\n.glyphicon-bookmark { &:before { content: \"\\e044\"; } }\n.glyphicon-print { &:before { content: \"\\e045\"; } }\n.glyphicon-camera { &:before { content: \"\\e046\"; } }\n.glyphicon-font { &:before { content: \"\\e047\"; } }\n.glyphicon-bold { &:before { content: \"\\e048\"; } }\n.glyphicon-italic { &:before { content: \"\\e049\"; } }\n.glyphicon-text-height { &:before { content: \"\\e050\"; } }\n.glyphicon-text-width { &:before { content: \"\\e051\"; } }\n.glyphicon-align-left { &:before { content: \"\\e052\"; } }\n.glyphicon-align-center { &:before { content: \"\\e053\"; } }\n.glyphicon-align-right { &:before { content: \"\\e054\"; } }\n.glyphicon-align-justify { &:before { content: \"\\e055\"; } }\n.glyphicon-list { &:before { content: \"\\e056\"; } }\n.glyphicon-indent-left { &:before { content: \"\\e057\"; } }\n.glyphicon-indent-right { &:before { content: \"\\e058\"; } }\n.glyphicon-facetime-video { &:before { content: \"\\e059\"; } }\n.glyphicon-picture { &:before { content: \"\\e060\"; } }\n.glyphicon-map-marker { &:before { content: \"\\e062\"; } }\n.glyphicon-adjust { &:before { content: \"\\e063\"; } }\n.glyphicon-tint { &:before { content: \"\\e064\"; } }\n.glyphicon-edit { &:before { content: \"\\e065\"; } }\n.glyphicon-share { &:before { content: \"\\e066\"; } }\n.glyphicon-check { &:before { content: \"\\e067\"; } }\n.glyphicon-move { &:before { content: \"\\e068\"; } }\n.glyphicon-step-backward { &:before { content: \"\\e069\"; } }\n.glyphicon-fast-backward { &:before { content: \"\\e070\"; } }\n.glyphicon-backward { &:before { content: \"\\e071\"; } }\n.glyphicon-play { &:before { content: \"\\e072\"; } }\n.glyphicon-pause { &:before { content: \"\\e073\"; } }\n.glyphicon-stop { &:before { content: \"\\e074\"; } }\n.glyphicon-forward { &:before { content: \"\\e075\"; } }\n.glyphicon-fast-forward { &:before { content: \"\\e076\"; } }\n.glyphicon-step-forward { &:before { content: \"\\e077\"; } }\n.glyphicon-eject { &:before { content: \"\\e078\"; } }\n.glyphicon-chevron-left { &:before { content: \"\\e079\"; } }\n.glyphicon-chevron-right { &:before { content: \"\\e080\"; } }\n.glyphicon-plus-sign { &:before { content: \"\\e081\"; } }\n.glyphicon-minus-sign { &:before { content: \"\\e082\"; } }\n.glyphicon-remove-sign { &:before { content: \"\\e083\"; } }\n.glyphicon-ok-sign { &:before { content: \"\\e084\"; } }\n.glyphicon-question-sign { &:before { content: \"\\e085\"; } }\n.glyphicon-info-sign { &:before { content: \"\\e086\"; } }\n.glyphicon-screenshot { &:before { content: \"\\e087\"; } }\n.glyphicon-remove-circle { &:before { content: \"\\e088\"; } }\n.glyphicon-ok-circle { &:before { content: \"\\e089\"; } }\n.glyphicon-ban-circle { &:before { content: \"\\e090\"; } }\n.glyphicon-arrow-left { &:before { content: \"\\e091\"; } }\n.glyphicon-arrow-right { &:before { content: \"\\e092\"; } }\n.glyphicon-arrow-up { &:before { content: \"\\e093\"; } }\n.glyphicon-arrow-down { &:before { content: \"\\e094\"; } }\n.glyphicon-share-alt { &:before { content: \"\\e095\"; } }\n.glyphicon-resize-full { &:before { content: \"\\e096\"; } }\n.glyphicon-resize-small { &:before { content: \"\\e097\"; } }\n.glyphicon-exclamation-sign { &:before { content: \"\\e101\"; } }\n.glyphicon-gift { &:before { content: \"\\e102\"; } }\n.glyphicon-leaf { &:before { content: \"\\e103\"; } }\n.glyphicon-fire { &:before { content: \"\\e104\"; } }\n.glyphicon-eye-open { &:before { content: \"\\e105\"; } }\n.glyphicon-eye-close { &:before { content: \"\\e106\"; } }\n.glyphicon-warning-sign { &:before { content: \"\\e107\"; } }\n.glyphicon-plane { &:before { content: \"\\e108\"; } }\n.glyphicon-calendar { &:before { content: \"\\e109\"; } }\n.glyphicon-random { &:before { content: \"\\e110\"; } }\n.glyphicon-comment { &:before { content: \"\\e111\"; } }\n.glyphicon-magnet { &:before { content: \"\\e112\"; } }\n.glyphicon-chevron-up { &:before { content: \"\\e113\"; } }\n.glyphicon-chevron-down { &:before { content: \"\\e114\"; } }\n.glyphicon-retweet { &:before { content: \"\\e115\"; } }\n.glyphicon-shopping-cart { &:before { content: \"\\e116\"; } }\n.glyphicon-folder-close { &:before { content: \"\\e117\"; } }\n.glyphicon-folder-open { &:before { content: \"\\e118\"; } }\n.glyphicon-resize-vertical { &:before { content: \"\\e119\"; } }\n.glyphicon-resize-horizontal { &:before { content: \"\\e120\"; } }\n.glyphicon-hdd { &:before { content: \"\\e121\"; } }\n.glyphicon-bullhorn { &:before { content: \"\\e122\"; } }\n.glyphicon-bell { &:before { content: \"\\e123\"; } }\n.glyphicon-certificate { &:before { content: \"\\e124\"; } }\n.glyphicon-thumbs-up { &:before { content: \"\\e125\"; } }\n.glyphicon-thumbs-down { &:before { content: \"\\e126\"; } }\n.glyphicon-hand-right { &:before { content: \"\\e127\"; } }\n.glyphicon-hand-left { &:before { content: \"\\e128\"; } }\n.glyphicon-hand-up { &:before { content: \"\\e129\"; } }\n.glyphicon-hand-down { &:before { content: \"\\e130\"; } }\n.glyphicon-circle-arrow-right { &:before { content: \"\\e131\"; } }\n.glyphicon-circle-arrow-left { &:before { content: \"\\e132\"; } }\n.glyphicon-circle-arrow-up { &:before { content: \"\\e133\"; } }\n.glyphicon-circle-arrow-down { &:before { content: \"\\e134\"; } }\n.glyphicon-globe { &:before { content: \"\\e135\"; } }\n.glyphicon-wrench { &:before { content: \"\\e136\"; } }\n.glyphicon-tasks { &:before { content: \"\\e137\"; } }\n.glyphicon-filter { &:before { content: \"\\e138\"; } }\n.glyphicon-briefcase { &:before { content: \"\\e139\"; } }\n.glyphicon-fullscreen { &:before { content: \"\\e140\"; } }\n.glyphicon-dashboard { &:before { content: \"\\e141\"; } }\n.glyphicon-paperclip { &:before { content: \"\\e142\"; } }\n.glyphicon-heart-empty { &:before { content: \"\\e143\"; } }\n.glyphicon-link { &:before { content: \"\\e144\"; } }\n.glyphicon-phone { &:before { content: \"\\e145\"; } }\n.glyphicon-pushpin { &:before { content: \"\\e146\"; } }\n.glyphicon-usd { &:before { content: \"\\e148\"; } }\n.glyphicon-gbp { &:before { content: \"\\e149\"; } }\n.glyphicon-sort { &:before { content: \"\\e150\"; } }\n.glyphicon-sort-by-alphabet { &:before { content: \"\\e151\"; } }\n.glyphicon-sort-by-alphabet-alt { &:before { content: \"\\e152\"; } }\n.glyphicon-sort-by-order { &:before { content: \"\\e153\"; } }\n.glyphicon-sort-by-order-alt { &:before { content: \"\\e154\"; } }\n.glyphicon-sort-by-attributes { &:before { content: \"\\e155\"; } }\n.glyphicon-sort-by-attributes-alt { &:before { content: \"\\e156\"; } }\n.glyphicon-unchecked { &:before { content: \"\\e157\"; } }\n.glyphicon-expand { &:before { content: \"\\e158\"; } }\n.glyphicon-collapse-down { &:before { content: \"\\e159\"; } }\n.glyphicon-collapse-up { &:before { content: \"\\e160\"; } }\n.glyphicon-log-in { &:before { content: \"\\e161\"; } }\n.glyphicon-flash { &:before { content: \"\\e162\"; } }\n.glyphicon-log-out { &:before { content: \"\\e163\"; } }\n.glyphicon-new-window { &:before { content: \"\\e164\"; } }\n.glyphicon-record { &:before { content: \"\\e165\"; } }\n.glyphicon-save { &:before { content: \"\\e166\"; } }\n.glyphicon-open { &:before { content: \"\\e167\"; } }\n.glyphicon-saved { &:before { content: \"\\e168\"; } }\n.glyphicon-import { &:before { content: \"\\e169\"; } }\n.glyphicon-export { &:before { content: \"\\e170\"; } }\n.glyphicon-send { &:before { content: \"\\e171\"; } }\n.glyphicon-floppy-disk { &:before { content: \"\\e172\"; } }\n.glyphicon-floppy-saved { &:before { content: \"\\e173\"; } }\n.glyphicon-floppy-remove { &:before { content: \"\\e174\"; } }\n.glyphicon-floppy-save { &:before { content: \"\\e175\"; } }\n.glyphicon-floppy-open { &:before { content: \"\\e176\"; } }\n.glyphicon-credit-card { &:before { content: \"\\e177\"; } }\n.glyphicon-transfer { &:before { content: \"\\e178\"; } }\n.glyphicon-cutlery { &:before { content: \"\\e179\"; } }\n.glyphicon-header { &:before { content: \"\\e180\"; } }\n.glyphicon-compressed { &:before { content: \"\\e181\"; } }\n.glyphicon-earphone { &:before { content: \"\\e182\"; } }\n.glyphicon-phone-alt { &:before { content: \"\\e183\"; } }\n.glyphicon-tower { &:before { content: \"\\e184\"; } }\n.glyphicon-stats { &:before { content: \"\\e185\"; } }\n.glyphicon-sd-video { &:before { content: \"\\e186\"; } }\n.glyphicon-hd-video { &:before { content: \"\\e187\"; } }\n.glyphicon-subtitles { &:before { content: \"\\e188\"; } }\n.glyphicon-sound-stereo { &:before { content: \"\\e189\"; } }\n.glyphicon-sound-dolby { &:before { content: \"\\e190\"; } }\n.glyphicon-sound-5-1 { &:before { content: \"\\e191\"; } }\n.glyphicon-sound-6-1 { &:before { content: \"\\e192\"; } }\n.glyphicon-sound-7-1 { &:before { content: \"\\e193\"; } }\n.glyphicon-copyright-mark { &:before { content: \"\\e194\"; } }\n.glyphicon-registration-mark { &:before { content: \"\\e195\"; } }\n.glyphicon-cloud-download { &:before { content: \"\\e197\"; } }\n.glyphicon-cloud-upload { &:before { content: \"\\e198\"; } }\n.glyphicon-tree-conifer { &:before { content: \"\\e199\"; } }\n.glyphicon-tree-deciduous { &:before { content: \"\\e200\"; } }\n.glyphicon-cd { &:before { content: \"\\e201\"; } }\n.glyphicon-save-file { &:before { content: \"\\e202\"; } }\n.glyphicon-open-file { &:before { content: \"\\e203\"; } }\n.glyphicon-level-up { &:before { content: \"\\e204\"; } }\n.glyphicon-copy { &:before { content: \"\\e205\"; } }\n.glyphicon-paste { &:before { content: \"\\e206\"; } }\n// The following 2 Glyphicons are omitted for the time being because\n// they currently use Unicode codepoints that are outside the\n// Basic Multilingual Plane (BMP). Older buggy versions of WebKit can't handle\n// non-BMP codepoints in CSS string escapes, and thus can't display these two icons.\n// Notably, the bug affects some older versions of the Android Browser.\n// More info: https://github.com/twbs/bootstrap/issues/10106\n// .glyphicon-door { &:before { content: \"\\1f6aa\"; } }\n// .glyphicon-key { &:before { content: \"\\1f511\"; } }\n.glyphicon-alert { &:before { content: \"\\e209\"; } }\n.glyphicon-equalizer { &:before { content: \"\\e210\"; } }\n.glyphicon-king { &:before { content: \"\\e211\"; } }\n.glyphicon-queen { &:before { content: \"\\e212\"; } }\n.glyphicon-pawn { &:before { content: \"\\e213\"; } }\n.glyphicon-bishop { &:before { content: \"\\e214\"; } }\n.glyphicon-knight { &:before { content: \"\\e215\"; } }\n.glyphicon-baby-formula { &:before { content: \"\\e216\"; } }\n.glyphicon-tent { &:before { content: \"\\26fa\"; } }\n.glyphicon-blackboard { &:before { content: \"\\e218\"; } }\n.glyphicon-bed { &:before { content: \"\\e219\"; } }\n.glyphicon-apple { &:before { content: \"\\f8ff\"; } }\n.glyphicon-erase { &:before { content: \"\\e221\"; } }\n.glyphicon-hourglass { &:before { content: \"\\231b\"; } }\n.glyphicon-lamp { &:before { content: \"\\e223\"; } }\n.glyphicon-duplicate { &:before { content: \"\\e224\"; } }\n.glyphicon-piggy-bank { &:before { content: \"\\e225\"; } }\n.glyphicon-scissors { &:before { content: \"\\e226\"; } }\n.glyphicon-bitcoin { &:before { content: \"\\e227\"; } }\n.glyphicon-btc { &:before { content: \"\\e227\"; } }\n.glyphicon-xbt { &:before { content: \"\\e227\"; } }\n.glyphicon-yen { &:before { content: \"\\00a5\"; } }\n.glyphicon-jpy { &:before { content: \"\\00a5\"; } }\n.glyphicon-ruble { &:before { content: \"\\20bd\"; } }\n.glyphicon-rub { &:before { content: \"\\20bd\"; } }\n.glyphicon-scale { &:before { content: \"\\e230\"; } }\n.glyphicon-ice-lolly { &:before { content: \"\\e231\"; } }\n.glyphicon-ice-lolly-tasted { &:before { content: \"\\e232\"; } }\n.glyphicon-education { &:before { content: \"\\e233\"; } }\n.glyphicon-option-horizontal { &:before { content: \"\\e234\"; } }\n.glyphicon-option-vertical { &:before { content: \"\\e235\"; } }\n.glyphicon-menu-hamburger { &:before { content: \"\\e236\"; } }\n.glyphicon-modal-window { &:before { content: \"\\e237\"; } }\n.glyphicon-oil { &:before { content: \"\\e238\"; } }\n.glyphicon-grain { &:before { content: \"\\e239\"; } }\n.glyphicon-sunglasses { &:before { content: \"\\e240\"; } }\n.glyphicon-text-size { &:before { content: \"\\e241\"; } }\n.glyphicon-text-color { &:before { content: \"\\e242\"; } }\n.glyphicon-text-background { &:before { content: \"\\e243\"; } }\n.glyphicon-object-align-top { &:before { content: \"\\e244\"; } }\n.glyphicon-object-align-bottom { &:before { content: \"\\e245\"; } }\n.glyphicon-object-align-horizontal{ &:before { content: \"\\e246\"; } }\n.glyphicon-object-align-left { &:before { content: \"\\e247\"; } }\n.glyphicon-object-align-vertical { &:before { content: \"\\e248\"; } }\n.glyphicon-object-align-right { &:before { content: \"\\e249\"; } }\n.glyphicon-triangle-right { &:before { content: \"\\e250\"; } }\n.glyphicon-triangle-left { &:before { content: \"\\e251\"; } }\n.glyphicon-triangle-bottom { &:before { content: \"\\e252\"; } }\n.glyphicon-triangle-top { &:before { content: \"\\e253\"; } }\n.glyphicon-console { &:before { content: \"\\e254\"; } }\n.glyphicon-superscript { &:before { content: \"\\e255\"; } }\n.glyphicon-subscript { &:before { content: \"\\e256\"; } }\n.glyphicon-menu-left { &:before { content: \"\\e257\"; } }\n.glyphicon-menu-right { &:before { content: \"\\e258\"; } }\n.glyphicon-menu-down { &:before { content: \"\\e259\"; } }\n.glyphicon-menu-up { &:before { content: \"\\e260\"; } }\n","//\n// Scaffolding\n// --------------------------------------------------\n\n\n// Reset the box-sizing\n//\n// Heads up! This reset may cause conflicts with some third-party widgets.\n// For recommendations on resolving such conflicts, see\n// http://getbootstrap.com/getting-started/#third-box-sizing\n* {\n .box-sizing(border-box);\n}\n*:before,\n*:after {\n .box-sizing(border-box);\n}\n\n\n// Body reset\n\nhtml {\n font-size: 10px;\n -webkit-tap-highlight-color: rgba(0,0,0,0);\n}\n\nbody {\n font-family: @font-family-base;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @text-color;\n background-color: @body-bg;\n}\n\n// Reset fonts for relevant elements\ninput,\nbutton,\nselect,\ntextarea {\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\n\n// Links\n\na {\n color: @link-color;\n text-decoration: none;\n\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n }\n\n &:focus {\n .tab-focus();\n }\n}\n\n\n// Figures\n//\n// We reset this here because previously Normalize had no `figure` margins. This\n// ensures we don't break anyone's use of the element.\n\nfigure {\n margin: 0;\n}\n\n\n// Images\n\nimg {\n vertical-align: middle;\n}\n\n// Responsive images (ensure images don't scale beyond their parents)\n.img-responsive {\n .img-responsive();\n}\n\n// Rounded corners\n.img-rounded {\n border-radius: @border-radius-large;\n}\n\n// Image thumbnails\n//\n// Heads up! This is mixin-ed into thumbnails.less for `.thumbnail`.\n.img-thumbnail {\n padding: @thumbnail-padding;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(all .2s ease-in-out);\n\n // Keep them at most 100% wide\n .img-responsive(inline-block);\n}\n\n// Perfect circle\n.img-circle {\n border-radius: 50%; // set radius in percents\n}\n\n\n// Horizontal rules\n\nhr {\n margin-top: @line-height-computed;\n margin-bottom: @line-height-computed;\n border: 0;\n border-top: 1px solid @hr-border;\n}\n\n\n// Only display content to screen readers\n//\n// See: http://a11yproject.com/posts/how-to-hide-content/\n\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0,0,0,0);\n border: 0;\n}\n\n// Use in conjunction with .sr-only to only display content when it's focused.\n// Useful for \"Skip to main content\" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1\n// Credit: HTML5 Boilerplate\n\n.sr-only-focusable {\n &:active,\n &:focus {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n overflow: visible;\n clip: auto;\n }\n}\n\n\n// iOS \"clickable elements\" fix for role=\"button\"\n//\n// Fixes \"clickability\" issue (and more generally, the firing of events such as focus as well)\n// for traditionally non-focusable elements with role=\"button\"\n// see https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile\n\n[role=\"button\"] {\n cursor: pointer;\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They have been removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility) {\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// WebKit-style focus\n\n.tab-focus() {\n // Default\n outline: thin dotted;\n // WebKit\n outline: 5px auto -webkit-focus-ring-color;\n outline-offset: -2px;\n}\n","// Image Mixins\n// - Responsive image\n// - Retina image\n\n\n// Responsive image\n//\n// Keep images from scaling beyond the width of their parents.\n.img-responsive(@display: block) {\n display: @display;\n max-width: 100%; // Part 1: Set a maximum relative to the parent\n height: auto; // Part 2: Scale the height according to the width, otherwise you get stretching\n}\n\n\n// Retina image\n//\n// Short retina mixin for setting background-image and -size. Note that the\n// spelling of `min--moz-device-pixel-ratio` is intentional.\n.img-retina(@file-1x; @file-2x; @width-1x; @height-1x) {\n background-image: url(\"@{file-1x}\");\n\n @media\n only screen and (-webkit-min-device-pixel-ratio: 2),\n only screen and ( min--moz-device-pixel-ratio: 2),\n only screen and ( -o-min-device-pixel-ratio: 2/1),\n only screen and ( min-device-pixel-ratio: 2),\n only screen and ( min-resolution: 192dpi),\n only screen and ( min-resolution: 2dppx) {\n background-image: url(\"@{file-2x}\");\n background-size: @width-1x @height-1x;\n }\n}\n","//\n// Typography\n// --------------------------------------------------\n\n\n// Headings\n// -------------------------\n\nh1, h2, h3, h4, h5, h6,\n.h1, .h2, .h3, .h4, .h5, .h6 {\n font-family: @headings-font-family;\n font-weight: @headings-font-weight;\n line-height: @headings-line-height;\n color: @headings-color;\n\n small,\n .small {\n font-weight: normal;\n line-height: 1;\n color: @headings-small-color;\n }\n}\n\nh1, .h1,\nh2, .h2,\nh3, .h3 {\n margin-top: @line-height-computed;\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 65%;\n }\n}\nh4, .h4,\nh5, .h5,\nh6, .h6 {\n margin-top: (@line-height-computed / 2);\n margin-bottom: (@line-height-computed / 2);\n\n small,\n .small {\n font-size: 75%;\n }\n}\n\nh1, .h1 { font-size: @font-size-h1; }\nh2, .h2 { font-size: @font-size-h2; }\nh3, .h3 { font-size: @font-size-h3; }\nh4, .h4 { font-size: @font-size-h4; }\nh5, .h5 { font-size: @font-size-h5; }\nh6, .h6 { font-size: @font-size-h6; }\n\n\n// Body text\n// -------------------------\n\np {\n margin: 0 0 (@line-height-computed / 2);\n}\n\n.lead {\n margin-bottom: @line-height-computed;\n font-size: floor((@font-size-base * 1.15));\n font-weight: 300;\n line-height: 1.4;\n\n @media (min-width: @screen-sm-min) {\n font-size: (@font-size-base * 1.5);\n }\n}\n\n\n// Emphasis & misc\n// -------------------------\n\n// Ex: (12px small font / 14px base font) * 100% = about 85%\nsmall,\n.small {\n font-size: floor((100% * @font-size-small / @font-size-base));\n}\n\nmark,\n.mark {\n background-color: @state-warning-bg;\n padding: .2em;\n}\n\n// Alignment\n.text-left { text-align: left; }\n.text-right { text-align: right; }\n.text-center { text-align: center; }\n.text-justify { text-align: justify; }\n.text-nowrap { white-space: nowrap; }\n\n// Transformation\n.text-lowercase { text-transform: lowercase; }\n.text-uppercase { text-transform: uppercase; }\n.text-capitalize { text-transform: capitalize; }\n\n// Contextual colors\n.text-muted {\n color: @text-muted;\n}\n.text-primary {\n .text-emphasis-variant(@brand-primary);\n}\n.text-success {\n .text-emphasis-variant(@state-success-text);\n}\n.text-info {\n .text-emphasis-variant(@state-info-text);\n}\n.text-warning {\n .text-emphasis-variant(@state-warning-text);\n}\n.text-danger {\n .text-emphasis-variant(@state-danger-text);\n}\n\n// Contextual backgrounds\n// For now we'll leave these alongside the text classes until v4 when we can\n// safely shift things around (per SemVer rules).\n.bg-primary {\n // Given the contrast here, this is the only class to have its color inverted\n // automatically.\n color: #fff;\n .bg-variant(@brand-primary);\n}\n.bg-success {\n .bg-variant(@state-success-bg);\n}\n.bg-info {\n .bg-variant(@state-info-bg);\n}\n.bg-warning {\n .bg-variant(@state-warning-bg);\n}\n.bg-danger {\n .bg-variant(@state-danger-bg);\n}\n\n\n// Page header\n// -------------------------\n\n.page-header {\n padding-bottom: ((@line-height-computed / 2) - 1);\n margin: (@line-height-computed * 2) 0 @line-height-computed;\n border-bottom: 1px solid @page-header-border-color;\n}\n\n\n// Lists\n// -------------------------\n\n// Unordered and Ordered lists\nul,\nol {\n margin-top: 0;\n margin-bottom: (@line-height-computed / 2);\n ul,\n ol {\n margin-bottom: 0;\n }\n}\n\n// List options\n\n// Unstyled keeps list items block level, just removes default browser padding and list-style\n.list-unstyled {\n padding-left: 0;\n list-style: none;\n}\n\n// Inline turns list items into inline-block\n.list-inline {\n .list-unstyled();\n margin-left: -5px;\n\n > li {\n display: inline-block;\n padding-left: 5px;\n padding-right: 5px;\n }\n}\n\n// Description Lists\ndl {\n margin-top: 0; // Remove browser default\n margin-bottom: @line-height-computed;\n}\ndt,\ndd {\n line-height: @line-height-base;\n}\ndt {\n font-weight: bold;\n}\ndd {\n margin-left: 0; // Undo browser default\n}\n\n// Horizontal description lists\n//\n// Defaults to being stacked without any of the below styles applied, until the\n// grid breakpoint is reached (default of ~768px).\n\n.dl-horizontal {\n dd {\n &:extend(.clearfix all); // Clear the floated `dt` if an empty `dd` is present\n }\n\n @media (min-width: @dl-horizontal-breakpoint) {\n dt {\n float: left;\n width: (@dl-horizontal-offset - 20);\n clear: left;\n text-align: right;\n .text-overflow();\n }\n dd {\n margin-left: @dl-horizontal-offset;\n }\n }\n}\n\n\n// Misc\n// -------------------------\n\n// Abbreviations and acronyms\nabbr[title],\n// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257\nabbr[data-original-title] {\n cursor: help;\n border-bottom: 1px dotted @abbr-border-color;\n}\n.initialism {\n font-size: 90%;\n .text-uppercase();\n}\n\n// Blockquotes\nblockquote {\n padding: (@line-height-computed / 2) @line-height-computed;\n margin: 0 0 @line-height-computed;\n font-size: @blockquote-font-size;\n border-left: 5px solid @blockquote-border-color;\n\n p,\n ul,\n ol {\n &:last-child {\n margin-bottom: 0;\n }\n }\n\n // Note: Deprecated small and .small as of v3.1.0\n // Context: https://github.com/twbs/bootstrap/issues/11660\n footer,\n small,\n .small {\n display: block;\n font-size: 80%; // back to default font-size\n line-height: @line-height-base;\n color: @blockquote-small-color;\n\n &:before {\n content: '\\2014 \\00A0'; // em dash, nbsp\n }\n }\n}\n\n// Opposite alignment of blockquote\n//\n// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0.\n.blockquote-reverse,\nblockquote.pull-right {\n padding-right: 15px;\n padding-left: 0;\n border-right: 5px solid @blockquote-border-color;\n border-left: 0;\n text-align: right;\n\n // Account for citation\n footer,\n small,\n .small {\n &:before { content: ''; }\n &:after {\n content: '\\00A0 \\2014'; // nbsp, em dash\n }\n }\n}\n\n// Addresses\naddress {\n margin-bottom: @line-height-computed;\n font-style: normal;\n line-height: @line-height-base;\n}\n","// Typography\n\n.text-emphasis-variant(@color) {\n color: @color;\n a&:hover,\n a&:focus {\n color: darken(@color, 10%);\n }\n}\n","// Contextual backgrounds\n\n.bg-variant(@color) {\n background-color: @color;\n a&:hover,\n a&:focus {\n background-color: darken(@color, 10%);\n }\n}\n","// Text overflow\n// Requires inline-block or block for proper styling\n\n.text-overflow() {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n","//\n// Code (inline and block)\n// --------------------------------------------------\n\n\n// Inline and block code styles\ncode,\nkbd,\npre,\nsamp {\n font-family: @font-family-monospace;\n}\n\n// Inline code\ncode {\n padding: 2px 4px;\n font-size: 90%;\n color: @code-color;\n background-color: @code-bg;\n border-radius: @border-radius-base;\n}\n\n// User input typically entered via keyboard\nkbd {\n padding: 2px 4px;\n font-size: 90%;\n color: @kbd-color;\n background-color: @kbd-bg;\n border-radius: @border-radius-small;\n box-shadow: inset 0 -1px 0 rgba(0,0,0,.25);\n\n kbd {\n padding: 0;\n font-size: 100%;\n font-weight: bold;\n box-shadow: none;\n }\n}\n\n// Blocks of code\npre {\n display: block;\n padding: ((@line-height-computed - 1) / 2);\n margin: 0 0 (@line-height-computed / 2);\n font-size: (@font-size-base - 1); // 14px to 13px\n line-height: @line-height-base;\n word-break: break-all;\n word-wrap: break-word;\n color: @pre-color;\n background-color: @pre-bg;\n border: 1px solid @pre-border-color;\n border-radius: @border-radius-base;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n padding: 0;\n font-size: inherit;\n color: inherit;\n white-space: pre-wrap;\n background-color: transparent;\n border-radius: 0;\n }\n}\n\n// Enable scrollable blocks of code\n.pre-scrollable {\n max-height: @pre-scrollable-max-height;\n overflow-y: scroll;\n}\n","//\n// Grid system\n// --------------------------------------------------\n\n\n// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n.container {\n .container-fixed();\n\n @media (min-width: @screen-sm-min) {\n width: @container-sm;\n }\n @media (min-width: @screen-md-min) {\n width: @container-md;\n }\n @media (min-width: @screen-lg-min) {\n width: @container-lg;\n }\n}\n\n\n// Fluid container\n//\n// Utilizes the mixin meant for fixed width containers, but without any defined\n// width for fluid, full width layouts.\n\n.container-fluid {\n .container-fixed();\n}\n\n\n// Row\n//\n// Rows contain and clear the floats of your columns.\n\n.row {\n .make-row();\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n.make-grid-columns();\n\n\n// Extra small grid\n//\n// Columns, offsets, pushes, and pulls for extra small devices like\n// smartphones.\n\n.make-grid(xs);\n\n\n// Small grid\n//\n// Columns, offsets, pushes, and pulls for the small device range, from phones\n// to tablets.\n\n@media (min-width: @screen-sm-min) {\n .make-grid(sm);\n}\n\n\n// Medium grid\n//\n// Columns, offsets, pushes, and pulls for the desktop device range.\n\n@media (min-width: @screen-md-min) {\n .make-grid(md);\n}\n\n\n// Large grid\n//\n// Columns, offsets, pushes, and pulls for the large desktop device range.\n\n@media (min-width: @screen-lg-min) {\n .make-grid(lg);\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n// Centered container element\n.container-fixed(@gutter: @grid-gutter-width) {\n margin-right: auto;\n margin-left: auto;\n padding-left: floor((@gutter / 2));\n padding-right: ceil((@gutter / 2));\n &:extend(.clearfix all);\n}\n\n// Creates a wrapper for a series of columns\n.make-row(@gutter: @grid-gutter-width) {\n margin-left: ceil((@gutter / -2));\n margin-right: floor((@gutter / -2));\n &:extend(.clearfix all);\n}\n\n// Generate the extra small columns\n.make-xs-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n float: left;\n width: percentage((@columns / @grid-columns));\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n}\n.make-xs-column-offset(@columns) {\n margin-left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-push(@columns) {\n left: percentage((@columns / @grid-columns));\n}\n.make-xs-column-pull(@columns) {\n right: percentage((@columns / @grid-columns));\n}\n\n// Generate the small columns\n.make-sm-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-sm-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-offset(@columns) {\n @media (min-width: @screen-sm-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-push(@columns) {\n @media (min-width: @screen-sm-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-sm-column-pull(@columns) {\n @media (min-width: @screen-sm-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the medium columns\n.make-md-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-md-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-offset(@columns) {\n @media (min-width: @screen-md-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-push(@columns) {\n @media (min-width: @screen-md-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-md-column-pull(@columns) {\n @media (min-width: @screen-md-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n\n// Generate the large columns\n.make-lg-column(@columns; @gutter: @grid-gutter-width) {\n position: relative;\n min-height: 1px;\n padding-left: (@gutter / 2);\n padding-right: (@gutter / 2);\n\n @media (min-width: @screen-lg-min) {\n float: left;\n width: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-offset(@columns) {\n @media (min-width: @screen-lg-min) {\n margin-left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-push(@columns) {\n @media (min-width: @screen-lg-min) {\n left: percentage((@columns / @grid-columns));\n }\n}\n.make-lg-column-pull(@columns) {\n @media (min-width: @screen-lg-min) {\n right: percentage((@columns / @grid-columns));\n }\n}\n","// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `@grid-columns`.\n\n.make-grid-columns() {\n // Common styles for all sizes of grid columns, widths 1-12\n .col(@index) { // initial\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general; \"=<\" isn't a typo\n @item: ~\".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n position: relative;\n // Prevent columns from collapsing when empty\n min-height: 1px;\n // Inner gutter via padding\n padding-left: ceil((@grid-gutter-width / 2));\n padding-right: floor((@grid-gutter-width / 2));\n }\n }\n .col(1); // kickstart it\n}\n\n.float-grid-columns(@class) {\n .col(@index) { // initial\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), @item);\n }\n .col(@index, @list) when (@index =< @grid-columns) { // general\n @item: ~\".col-@{class}-@{index}\";\n .col((@index + 1), ~\"@{list}, @{item}\");\n }\n .col(@index, @list) when (@index > @grid-columns) { // terminal\n @{list} {\n float: left;\n }\n }\n .col(1); // kickstart it\n}\n\n.calc-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) {\n .col-@{class}-@{index} {\n width: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index > 0) {\n .col-@{class}-push-@{index} {\n left: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = push) and (@index = 0) {\n .col-@{class}-push-0 {\n left: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index > 0) {\n .col-@{class}-pull-@{index} {\n right: percentage((@index / @grid-columns));\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index = 0) {\n .col-@{class}-pull-0 {\n right: auto;\n }\n}\n.calc-grid-column(@index, @class, @type) when (@type = offset) {\n .col-@{class}-offset-@{index} {\n margin-left: percentage((@index / @grid-columns));\n }\n}\n\n// Basic looping in LESS\n.loop-grid-columns(@index, @class, @type) when (@index >= 0) {\n .calc-grid-column(@index, @class, @type);\n // next iteration\n .loop-grid-columns((@index - 1), @class, @type);\n}\n\n// Create grid for specific class\n.make-grid(@class) {\n .float-grid-columns(@class);\n .loop-grid-columns(@grid-columns, @class, width);\n .loop-grid-columns(@grid-columns, @class, pull);\n .loop-grid-columns(@grid-columns, @class, push);\n .loop-grid-columns(@grid-columns, @class, offset);\n}\n","//\n// Tables\n// --------------------------------------------------\n\n\ntable {\n background-color: @table-bg;\n}\ncaption {\n padding-top: @table-cell-padding;\n padding-bottom: @table-cell-padding;\n color: @text-muted;\n text-align: left;\n}\nth {\n text-align: left;\n}\n\n\n// Baseline styles\n\n.table {\n width: 100%;\n max-width: 100%;\n margin-bottom: @line-height-computed;\n // Cells\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-cell-padding;\n line-height: @line-height-base;\n vertical-align: top;\n border-top: 1px solid @table-border-color;\n }\n }\n }\n // Bottom align for column headings\n > thead > tr > th {\n vertical-align: bottom;\n border-bottom: 2px solid @table-border-color;\n }\n // Remove top border from thead by default\n > caption + thead,\n > colgroup + thead,\n > thead:first-child {\n > tr:first-child {\n > th,\n > td {\n border-top: 0;\n }\n }\n }\n // Account for multiple tbody instances\n > tbody + tbody {\n border-top: 2px solid @table-border-color;\n }\n\n // Nesting\n .table {\n background-color: @body-bg;\n }\n}\n\n\n// Condensed table w/ half padding\n\n.table-condensed {\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n padding: @table-condensed-cell-padding;\n }\n }\n }\n}\n\n\n// Bordered version\n//\n// Add borders all around the table and between all the columns.\n\n.table-bordered {\n border: 1px solid @table-border-color;\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n border: 1px solid @table-border-color;\n }\n }\n }\n > thead > tr {\n > th,\n > td {\n border-bottom-width: 2px;\n }\n }\n}\n\n\n// Zebra-striping\n//\n// Default zebra-stripe styles (alternating gray and transparent backgrounds)\n\n.table-striped {\n > tbody > tr:nth-of-type(odd) {\n background-color: @table-bg-accent;\n }\n}\n\n\n// Hover effect\n//\n// Placed here since it has to come after the potential zebra striping\n\n.table-hover {\n > tbody > tr:hover {\n background-color: @table-bg-hover;\n }\n}\n\n\n// Table cell sizing\n//\n// Reset default table behavior\n\ntable col[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-column;\n}\ntable {\n td,\n th {\n &[class*=\"col-\"] {\n position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623)\n float: none;\n display: table-cell;\n }\n }\n}\n\n\n// Table backgrounds\n//\n// Exact selectors below required to override `.table-striped` and prevent\n// inheritance to nested tables.\n\n// Generate the contextual variants\n.table-row-variant(active; @table-bg-active);\n.table-row-variant(success; @state-success-bg);\n.table-row-variant(info; @state-info-bg);\n.table-row-variant(warning; @state-warning-bg);\n.table-row-variant(danger; @state-danger-bg);\n\n\n// Responsive tables\n//\n// Wrap your tables in `.table-responsive` and we'll make them mobile friendly\n// by enabling horizontal scrolling. Only applies <768px. Everything above that\n// will display normally.\n\n.table-responsive {\n overflow-x: auto;\n min-height: 0.01%; // Workaround for IE9 bug (see https://github.com/twbs/bootstrap/issues/14837)\n\n @media screen and (max-width: @screen-xs-max) {\n width: 100%;\n margin-bottom: (@line-height-computed * 0.75);\n overflow-y: hidden;\n -ms-overflow-style: -ms-autohiding-scrollbar;\n border: 1px solid @table-border-color;\n\n // Tighten up spacing\n > .table {\n margin-bottom: 0;\n\n // Ensure the content doesn't wrap\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th,\n > td {\n white-space: nowrap;\n }\n }\n }\n }\n\n // Special overrides for the bordered tables\n > .table-bordered {\n border: 0;\n\n // Nuke the appropriate borders so that the parent can handle them\n > thead,\n > tbody,\n > tfoot {\n > tr {\n > th:first-child,\n > td:first-child {\n border-left: 0;\n }\n > th:last-child,\n > td:last-child {\n border-right: 0;\n }\n }\n }\n\n // Only nuke the last row's bottom-border in `tbody` and `tfoot` since\n // chances are there will be only one `tr` in a `thead` and that would\n // remove the border altogether.\n > tbody,\n > tfoot {\n > tr:last-child {\n > th,\n > td {\n border-bottom: 0;\n }\n }\n }\n\n }\n }\n}\n","// Tables\n\n.table-row-variant(@state; @background) {\n // Exact selectors below required to override `.table-striped` and prevent\n // inheritance to nested tables.\n .table > thead > tr,\n .table > tbody > tr,\n .table > tfoot > tr {\n > td.@{state},\n > th.@{state},\n &.@{state} > td,\n &.@{state} > th {\n background-color: @background;\n }\n }\n\n // Hover states for `.table-hover`\n // Note: this is not available for cells or rows within `thead` or `tfoot`.\n .table-hover > tbody > tr {\n > td.@{state}:hover,\n > th.@{state}:hover,\n &.@{state}:hover > td,\n &:hover > .@{state},\n &.@{state}:hover > th {\n background-color: darken(@background, 5%);\n }\n }\n}\n","//\n// Forms\n// --------------------------------------------------\n\n\n// Normalize non-controls\n//\n// Restyle and baseline non-control form elements.\n\nfieldset {\n padding: 0;\n margin: 0;\n border: 0;\n // Chrome and Firefox set a `min-width: min-content;` on fieldsets,\n // so we reset that to ensure it behaves more like a standard block element.\n // See https://github.com/twbs/bootstrap/issues/12359.\n min-width: 0;\n}\n\nlegend {\n display: block;\n width: 100%;\n padding: 0;\n margin-bottom: @line-height-computed;\n font-size: (@font-size-base * 1.5);\n line-height: inherit;\n color: @legend-color;\n border: 0;\n border-bottom: 1px solid @legend-border-color;\n}\n\nlabel {\n display: inline-block;\n max-width: 100%; // Force IE8 to wrap long content (see https://github.com/twbs/bootstrap/issues/13141)\n margin-bottom: 5px;\n font-weight: bold;\n}\n\n\n// Normalize form controls\n//\n// While most of our form styles require extra classes, some basic normalization\n// is required to ensure optimum display with or without those classes to better\n// address browser inconsistencies.\n\n// Override content-box in Normalize (* isn't specific enough)\ninput[type=\"search\"] {\n .box-sizing(border-box);\n}\n\n// Position radios and checkboxes better\ninput[type=\"radio\"],\ninput[type=\"checkbox\"] {\n margin: 4px 0 0;\n margin-top: 1px \\9; // IE8-9\n line-height: normal;\n}\n\ninput[type=\"file\"] {\n display: block;\n}\n\n// Make range inputs behave like textual form controls\ninput[type=\"range\"] {\n display: block;\n width: 100%;\n}\n\n// Make multiple select elements height not fixed\nselect[multiple],\nselect[size] {\n height: auto;\n}\n\n// Focus for file, radio, and checkbox\ninput[type=\"file\"]:focus,\ninput[type=\"radio\"]:focus,\ninput[type=\"checkbox\"]:focus {\n .tab-focus();\n}\n\n// Adjust output element\noutput {\n display: block;\n padding-top: (@padding-base-vertical + 1);\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n}\n\n\n// Common form controls\n//\n// Shared size and type resets for form controls. Apply `.form-control` to any\n// of the following form controls:\n//\n// select\n// textarea\n// input[type=\"text\"]\n// input[type=\"password\"]\n// input[type=\"datetime\"]\n// input[type=\"datetime-local\"]\n// input[type=\"date\"]\n// input[type=\"month\"]\n// input[type=\"time\"]\n// input[type=\"week\"]\n// input[type=\"number\"]\n// input[type=\"email\"]\n// input[type=\"url\"]\n// input[type=\"search\"]\n// input[type=\"tel\"]\n// input[type=\"color\"]\n\n.form-control {\n display: block;\n width: 100%;\n height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border)\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n line-height: @line-height-base;\n color: @input-color;\n background-color: @input-bg;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid @input-border;\n border-radius: @input-border-radius; // Note: This has no effect on s in CSS.\n .box-shadow(inset 0 1px 1px rgba(0,0,0,.075));\n .transition(~\"border-color ease-in-out .15s, box-shadow ease-in-out .15s\");\n\n // Customize the `:focus` state to imitate native WebKit styles.\n .form-control-focus();\n\n // Placeholder\n .placeholder();\n\n // Unstyle the caret on ``\n// element gets special love because it's special, and that's a fact!\n.input-size(@input-height; @padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n height: @input-height;\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n\n select& {\n height: @input-height;\n line-height: @input-height;\n }\n\n textarea&,\n select[multiple]& {\n height: auto;\n }\n}\n","//\n// Buttons\n// --------------------------------------------------\n\n\n// Base styles\n// --------------------------------------------------\n\n.btn {\n display: inline-block;\n margin-bottom: 0; // For input.btn\n font-weight: @btn-font-weight;\n text-align: center;\n vertical-align: middle;\n touch-action: manipulation;\n cursor: pointer;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid transparent;\n white-space: nowrap;\n .button-size(@padding-base-vertical; @padding-base-horizontal; @font-size-base; @line-height-base; @btn-border-radius-base);\n .user-select(none);\n\n &,\n &:active,\n &.active {\n &:focus,\n &.focus {\n .tab-focus();\n }\n }\n\n &:hover,\n &:focus,\n &.focus {\n color: @btn-default-color;\n text-decoration: none;\n }\n\n &:active,\n &.active {\n outline: 0;\n background-image: none;\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n cursor: @cursor-disabled;\n .opacity(.65);\n .box-shadow(none);\n }\n\n a& {\n &.disabled,\n fieldset[disabled] & {\n pointer-events: none; // Future-proof disabling of clicks on `` elements\n }\n }\n}\n\n\n// Alternate buttons\n// --------------------------------------------------\n\n.btn-default {\n .button-variant(@btn-default-color; @btn-default-bg; @btn-default-border);\n}\n.btn-primary {\n .button-variant(@btn-primary-color; @btn-primary-bg; @btn-primary-border);\n}\n// Success appears as green\n.btn-success {\n .button-variant(@btn-success-color; @btn-success-bg; @btn-success-border);\n}\n// Info appears as blue-green\n.btn-info {\n .button-variant(@btn-info-color; @btn-info-bg; @btn-info-border);\n}\n// Warning appears as orange\n.btn-warning {\n .button-variant(@btn-warning-color; @btn-warning-bg; @btn-warning-border);\n}\n// Danger and error appear as red\n.btn-danger {\n .button-variant(@btn-danger-color; @btn-danger-bg; @btn-danger-border);\n}\n\n\n// Link buttons\n// -------------------------\n\n// Make a button look and behave like a link\n.btn-link {\n color: @link-color;\n font-weight: normal;\n border-radius: 0;\n\n &,\n &:active,\n &.active,\n &[disabled],\n fieldset[disabled] & {\n background-color: transparent;\n .box-shadow(none);\n }\n &,\n &:hover,\n &:focus,\n &:active {\n border-color: transparent;\n }\n &:hover,\n &:focus {\n color: @link-hover-color;\n text-decoration: @link-hover-decoration;\n background-color: transparent;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @btn-link-disabled-color;\n text-decoration: none;\n }\n }\n}\n\n\n// Button Sizes\n// --------------------------------------------------\n\n.btn-lg {\n // line-height: ensure even-numbered height of button next to large input\n .button-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @btn-border-radius-large);\n}\n.btn-sm {\n // line-height: ensure proper height of button next to small input\n .button-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small);\n}\n.btn-xs {\n .button-size(@padding-xs-vertical; @padding-xs-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small);\n}\n\n\n// Block button\n// --------------------------------------------------\n\n.btn-block {\n display: block;\n width: 100%;\n}\n\n// Vertically space out multiple block buttons\n.btn-block + .btn-block {\n margin-top: 5px;\n}\n\n// Specificity overrides\ninput[type=\"submit\"],\ninput[type=\"reset\"],\ninput[type=\"button\"] {\n &.btn-block {\n width: 100%;\n }\n}\n","// Button variants\n//\n// Easily pump out default styles, as well as :hover, :focus, :active,\n// and disabled options for all buttons\n\n.button-variant(@color; @background; @border) {\n color: @color;\n background-color: @background;\n border-color: @border;\n\n &:focus,\n &.focus {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 25%);\n }\n &:hover {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 12%);\n }\n &:active,\n &.active,\n .open > .dropdown-toggle& {\n color: @color;\n background-color: darken(@background, 10%);\n border-color: darken(@border, 12%);\n\n &:hover,\n &:focus,\n &.focus {\n color: @color;\n background-color: darken(@background, 17%);\n border-color: darken(@border, 25%);\n }\n }\n &:active,\n &.active,\n .open > .dropdown-toggle& {\n background-image: none;\n }\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus,\n &.focus {\n background-color: @background;\n border-color: @border;\n }\n }\n\n .badge {\n color: @background;\n background-color: @color;\n }\n}\n\n// Button sizes\n.button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n border-radius: @border-radius;\n}\n","// Opacity\n\n.opacity(@opacity) {\n opacity: @opacity;\n // IE8 filter\n @opacity-ie: (@opacity * 100);\n filter: ~\"alpha(opacity=@{opacity-ie})\";\n}\n","//\n// Component animations\n// --------------------------------------------------\n\n// Heads up!\n//\n// We don't use the `.opacity()` mixin here since it causes a bug with text\n// fields in IE7-8. Source: https://github.com/twbs/bootstrap/pull/3552.\n\n.fade {\n opacity: 0;\n .transition(opacity .15s linear);\n &.in {\n opacity: 1;\n }\n}\n\n.collapse {\n display: none;\n\n &.in { display: block; }\n tr&.in { display: table-row; }\n tbody&.in { display: table-row-group; }\n}\n\n.collapsing {\n position: relative;\n height: 0;\n overflow: hidden;\n .transition-property(~\"height, visibility\");\n .transition-duration(.35s);\n .transition-timing-function(ease);\n}\n","//\n// Dropdown menus\n// --------------------------------------------------\n\n\n// Dropdown arrow/caret\n.caret {\n display: inline-block;\n width: 0;\n height: 0;\n margin-left: 2px;\n vertical-align: middle;\n border-top: @caret-width-base dashed;\n border-top: @caret-width-base solid ~\"\\9\"; // IE8\n border-right: @caret-width-base solid transparent;\n border-left: @caret-width-base solid transparent;\n}\n\n// The dropdown wrapper (div)\n.dropup,\n.dropdown {\n position: relative;\n}\n\n// Prevent the focus on the dropdown toggle when closing dropdowns\n.dropdown-toggle:focus {\n outline: 0;\n}\n\n// The dropdown menu (ul)\n.dropdown-menu {\n position: absolute;\n top: 100%;\n left: 0;\n z-index: @zindex-dropdown;\n display: none; // none by default, but block on \"open\" of the menu\n float: left;\n min-width: 160px;\n padding: 5px 0;\n margin: 2px 0 0; // override default ul\n list-style: none;\n font-size: @font-size-base;\n text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)\n background-color: @dropdown-bg;\n border: 1px solid @dropdown-fallback-border; // IE8 fallback\n border: 1px solid @dropdown-border;\n border-radius: @border-radius-base;\n .box-shadow(0 6px 12px rgba(0,0,0,.175));\n background-clip: padding-box;\n\n // Aligns the dropdown menu to right\n //\n // Deprecated as of 3.1.0 in favor of `.dropdown-menu-[dir]`\n &.pull-right {\n right: 0;\n left: auto;\n }\n\n // Dividers (basically an hr) within the dropdown\n .divider {\n .nav-divider(@dropdown-divider-bg);\n }\n\n // Links within the dropdown menu\n > li > a {\n display: block;\n padding: 3px 20px;\n clear: both;\n font-weight: normal;\n line-height: @line-height-base;\n color: @dropdown-link-color;\n white-space: nowrap; // prevent links from randomly breaking onto new lines\n }\n}\n\n// Hover/Focus state\n.dropdown-menu > li > a {\n &:hover,\n &:focus {\n text-decoration: none;\n color: @dropdown-link-hover-color;\n background-color: @dropdown-link-hover-bg;\n }\n}\n\n// Active state\n.dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: @dropdown-link-active-color;\n text-decoration: none;\n outline: 0;\n background-color: @dropdown-link-active-bg;\n }\n}\n\n// Disabled state\n//\n// Gray out text and ensure the hover/focus state remains gray\n\n.dropdown-menu > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @dropdown-link-disabled-color;\n }\n\n // Nuke hover/focus effects\n &:hover,\n &:focus {\n text-decoration: none;\n background-color: transparent;\n background-image: none; // Remove CSS gradient\n .reset-filter();\n cursor: @cursor-disabled;\n }\n}\n\n// Open state for the dropdown\n.open {\n // Show the menu\n > .dropdown-menu {\n display: block;\n }\n\n // Remove the outline when :focus is triggered\n > a {\n outline: 0;\n }\n}\n\n// Menu positioning\n//\n// Add extra class to `.dropdown-menu` to flip the alignment of the dropdown\n// menu with the parent.\n.dropdown-menu-right {\n left: auto; // Reset the default from `.dropdown-menu`\n right: 0;\n}\n// With v3, we enabled auto-flipping if you have a dropdown within a right\n// aligned nav component. To enable the undoing of that, we provide an override\n// to restore the default dropdown menu alignment.\n//\n// This is only for left-aligning a dropdown menu within a `.navbar-right` or\n// `.pull-right` nav component.\n.dropdown-menu-left {\n left: 0;\n right: auto;\n}\n\n// Dropdown section headers\n.dropdown-header {\n display: block;\n padding: 3px 20px;\n font-size: @font-size-small;\n line-height: @line-height-base;\n color: @dropdown-header-color;\n white-space: nowrap; // as with > li > a\n}\n\n// Backdrop to catch body clicks on mobile, etc.\n.dropdown-backdrop {\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n top: 0;\n z-index: (@zindex-dropdown - 10);\n}\n\n// Right aligned dropdowns\n.pull-right > .dropdown-menu {\n right: 0;\n left: auto;\n}\n\n// Allow for dropdowns to go bottom up (aka, dropup-menu)\n//\n// Just add .dropup after the standard .dropdown class and you're set, bro.\n// TODO: abstract this so that the navbar fixed styles are not placed here?\n\n.dropup,\n.navbar-fixed-bottom .dropdown {\n // Reverse the caret\n .caret {\n border-top: 0;\n border-bottom: @caret-width-base dashed;\n border-bottom: @caret-width-base solid ~\"\\9\"; // IE8\n content: \"\";\n }\n // Different positioning for bottom up menu\n .dropdown-menu {\n top: auto;\n bottom: 100%;\n margin-bottom: 2px;\n }\n}\n\n\n// Component alignment\n//\n// Reiterate per navbar.less and the modified component alignment there.\n\n@media (min-width: @grid-float-breakpoint) {\n .navbar-right {\n .dropdown-menu {\n .dropdown-menu-right();\n }\n // Necessary for overrides of the default right aligned menu.\n // Will remove come v4 in all likelihood.\n .dropdown-menu-left {\n .dropdown-menu-left();\n }\n }\n}\n","// Horizontal dividers\n//\n// Dividers (basically an hr) within dropdowns and nav lists\n\n.nav-divider(@color: #e5e5e5) {\n height: 1px;\n margin: ((@line-height-computed / 2) - 1) 0;\n overflow: hidden;\n background-color: @color;\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n","//\n// Button groups\n// --------------------------------------------------\n\n// Make the div behave like a button\n.btn-group,\n.btn-group-vertical {\n position: relative;\n display: inline-block;\n vertical-align: middle; // match .btn alignment given font-size hack above\n > .btn {\n position: relative;\n float: left;\n // Bring the \"active\" button to the front\n &:hover,\n &:focus,\n &:active,\n &.active {\n z-index: 2;\n }\n }\n}\n\n// Prevent double borders when buttons are next to each other\n.btn-group {\n .btn + .btn,\n .btn + .btn-group,\n .btn-group + .btn,\n .btn-group + .btn-group {\n margin-left: -1px;\n }\n}\n\n// Optional: Group multiple button groups together for a toolbar\n.btn-toolbar {\n margin-left: -5px; // Offset the first child's margin\n &:extend(.clearfix all);\n\n .btn,\n .btn-group,\n .input-group {\n float: left;\n }\n > .btn,\n > .btn-group,\n > .input-group {\n margin-left: 5px;\n }\n}\n\n.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) {\n border-radius: 0;\n}\n\n// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match\n.btn-group > .btn:first-child {\n margin-left: 0;\n &:not(:last-child):not(.dropdown-toggle) {\n .border-right-radius(0);\n }\n}\n// Need .dropdown-toggle since :last-child doesn't apply given a .dropdown-menu immediately after it\n.btn-group > .btn:last-child:not(:first-child),\n.btn-group > .dropdown-toggle:not(:first-child) {\n .border-left-radius(0);\n}\n\n// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group)\n.btn-group > .btn-group {\n float: left;\n}\n.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group > .btn-group:first-child:not(:last-child) {\n > .btn:last-child,\n > .dropdown-toggle {\n .border-right-radius(0);\n }\n}\n.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child {\n .border-left-radius(0);\n}\n\n// On active and open, don't show outline\n.btn-group .dropdown-toggle:active,\n.btn-group.open .dropdown-toggle {\n outline: 0;\n}\n\n\n// Sizing\n//\n// Remix the default button sizing classes into new ones for easier manipulation.\n\n.btn-group-xs > .btn { &:extend(.btn-xs); }\n.btn-group-sm > .btn { &:extend(.btn-sm); }\n.btn-group-lg > .btn { &:extend(.btn-lg); }\n\n\n// Split button dropdowns\n// ----------------------\n\n// Give the line between buttons some depth\n.btn-group > .btn + .dropdown-toggle {\n padding-left: 8px;\n padding-right: 8px;\n}\n.btn-group > .btn-lg + .dropdown-toggle {\n padding-left: 12px;\n padding-right: 12px;\n}\n\n// The clickable button for toggling the menu\n// Remove the gradient and set the same inset shadow as the :active state\n.btn-group.open .dropdown-toggle {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n\n // Show no shadow for `.btn-link` since it has no other button styles.\n &.btn-link {\n .box-shadow(none);\n }\n}\n\n\n// Reposition the caret\n.btn .caret {\n margin-left: 0;\n}\n// Carets in other button sizes\n.btn-lg .caret {\n border-width: @caret-width-large @caret-width-large 0;\n border-bottom-width: 0;\n}\n// Upside down carets for .dropup\n.dropup .btn-lg .caret {\n border-width: 0 @caret-width-large @caret-width-large;\n}\n\n\n// Vertical button groups\n// ----------------------\n\n.btn-group-vertical {\n > .btn,\n > .btn-group,\n > .btn-group > .btn {\n display: block;\n float: none;\n width: 100%;\n max-width: 100%;\n }\n\n // Clear floats so dropdown menus can be properly placed\n > .btn-group {\n &:extend(.clearfix all);\n > .btn {\n float: none;\n }\n }\n\n > .btn + .btn,\n > .btn + .btn-group,\n > .btn-group + .btn,\n > .btn-group + .btn-group {\n margin-top: -1px;\n margin-left: 0;\n }\n}\n\n.btn-group-vertical > .btn {\n &:not(:first-child):not(:last-child) {\n border-radius: 0;\n }\n &:first-child:not(:last-child) {\n .border-top-radius(@btn-border-radius-base);\n .border-bottom-radius(0);\n }\n &:last-child:not(:first-child) {\n .border-top-radius(0);\n .border-bottom-radius(@btn-border-radius-base);\n }\n}\n.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn {\n border-radius: 0;\n}\n.btn-group-vertical > .btn-group:first-child:not(:last-child) {\n > .btn:last-child,\n > .dropdown-toggle {\n .border-bottom-radius(0);\n }\n}\n.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child {\n .border-top-radius(0);\n}\n\n\n// Justified button groups\n// ----------------------\n\n.btn-group-justified {\n display: table;\n width: 100%;\n table-layout: fixed;\n border-collapse: separate;\n > .btn,\n > .btn-group {\n float: none;\n display: table-cell;\n width: 1%;\n }\n > .btn-group .btn {\n width: 100%;\n }\n\n > .btn-group .dropdown-menu {\n left: auto;\n }\n}\n\n\n// Checkbox and radio options\n//\n// In order to support the browser's form validation feedback, powered by the\n// `required` attribute, we have to \"hide\" the inputs via `clip`. We cannot use\n// `display: none;` or `visibility: hidden;` as that also hides the popover.\n// Simply visually hiding the inputs via `opacity` would leave them clickable in\n// certain cases which is prevented by using `clip` and `pointer-events`.\n// This way, we ensure a DOM element is visible to position the popover from.\n//\n// See https://github.com/twbs/bootstrap/pull/12794 and\n// https://github.com/twbs/bootstrap/pull/14559 for more information.\n\n[data-toggle=\"buttons\"] {\n > .btn,\n > .btn-group > .btn {\n input[type=\"radio\"],\n input[type=\"checkbox\"] {\n position: absolute;\n clip: rect(0,0,0,0);\n pointer-events: none;\n }\n }\n}\n","// Single side border-radius\n\n.border-top-radius(@radius) {\n border-top-right-radius: @radius;\n border-top-left-radius: @radius;\n}\n.border-right-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-top-right-radius: @radius;\n}\n.border-bottom-radius(@radius) {\n border-bottom-right-radius: @radius;\n border-bottom-left-radius: @radius;\n}\n.border-left-radius(@radius) {\n border-bottom-left-radius: @radius;\n border-top-left-radius: @radius;\n}\n","//\n// Input groups\n// --------------------------------------------------\n\n// Base styles\n// -------------------------\n.input-group {\n position: relative; // For dropdowns\n display: table;\n border-collapse: separate; // prevent input groups from inheriting border styles from table cells when placed within a table\n\n // Undo padding and float of grid classes\n &[class*=\"col-\"] {\n float: none;\n padding-left: 0;\n padding-right: 0;\n }\n\n .form-control {\n // Ensure that the input is always above the *appended* addon button for\n // proper border colors.\n position: relative;\n z-index: 2;\n\n // IE9 fubars the placeholder attribute in text inputs and the arrows on\n // select elements in input groups. To fix it, we float the input. Details:\n // https://github.com/twbs/bootstrap/issues/11561#issuecomment-28936855\n float: left;\n\n width: 100%;\n margin-bottom: 0;\n \n &:focus {\n z-index: 3;\n }\n }\n}\n\n// Sizing options\n//\n// Remix the default form control sizing classes into new ones for easier\n// manipulation.\n\n.input-group-lg > .form-control,\n.input-group-lg > .input-group-addon,\n.input-group-lg > .input-group-btn > .btn {\n .input-lg();\n}\n.input-group-sm > .form-control,\n.input-group-sm > .input-group-addon,\n.input-group-sm > .input-group-btn > .btn {\n .input-sm();\n}\n\n\n// Display as table-cell\n// -------------------------\n.input-group-addon,\n.input-group-btn,\n.input-group .form-control {\n display: table-cell;\n\n &:not(:first-child):not(:last-child) {\n border-radius: 0;\n }\n}\n// Addon and addon wrapper for buttons\n.input-group-addon,\n.input-group-btn {\n width: 1%;\n white-space: nowrap;\n vertical-align: middle; // Match the inputs\n}\n\n// Text input groups\n// -------------------------\n.input-group-addon {\n padding: @padding-base-vertical @padding-base-horizontal;\n font-size: @font-size-base;\n font-weight: normal;\n line-height: 1;\n color: @input-color;\n text-align: center;\n background-color: @input-group-addon-bg;\n border: 1px solid @input-group-addon-border-color;\n border-radius: @input-border-radius;\n\n // Sizing\n &.input-sm {\n padding: @padding-small-vertical @padding-small-horizontal;\n font-size: @font-size-small;\n border-radius: @input-border-radius-small;\n }\n &.input-lg {\n padding: @padding-large-vertical @padding-large-horizontal;\n font-size: @font-size-large;\n border-radius: @input-border-radius-large;\n }\n\n // Nuke default margins from checkboxes and radios to vertically center within.\n input[type=\"radio\"],\n input[type=\"checkbox\"] {\n margin-top: 0;\n }\n}\n\n// Reset rounded corners\n.input-group .form-control:first-child,\n.input-group-addon:first-child,\n.input-group-btn:first-child > .btn,\n.input-group-btn:first-child > .btn-group > .btn,\n.input-group-btn:first-child > .dropdown-toggle,\n.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle),\n.input-group-btn:last-child > .btn-group:not(:last-child) > .btn {\n .border-right-radius(0);\n}\n.input-group-addon:first-child {\n border-right: 0;\n}\n.input-group .form-control:last-child,\n.input-group-addon:last-child,\n.input-group-btn:last-child > .btn,\n.input-group-btn:last-child > .btn-group > .btn,\n.input-group-btn:last-child > .dropdown-toggle,\n.input-group-btn:first-child > .btn:not(:first-child),\n.input-group-btn:first-child > .btn-group:not(:first-child) > .btn {\n .border-left-radius(0);\n}\n.input-group-addon:last-child {\n border-left: 0;\n}\n\n// Button input groups\n// -------------------------\n.input-group-btn {\n position: relative;\n // Jankily prevent input button groups from wrapping with `white-space` and\n // `font-size` in combination with `inline-block` on buttons.\n font-size: 0;\n white-space: nowrap;\n\n // Negative margin for spacing, position for bringing hovered/focused/actived\n // element above the siblings.\n > .btn {\n position: relative;\n + .btn {\n margin-left: -1px;\n }\n // Bring the \"active\" button to the front\n &:hover,\n &:focus,\n &:active {\n z-index: 2;\n }\n }\n\n // Negative margin to only have a 1px border between the two\n &:first-child {\n > .btn,\n > .btn-group {\n margin-right: -1px;\n }\n }\n &:last-child {\n > .btn,\n > .btn-group {\n z-index: 2;\n margin-left: -1px;\n }\n }\n}\n","//\n// Navs\n// --------------------------------------------------\n\n\n// Base class\n// --------------------------------------------------\n\n.nav {\n margin-bottom: 0;\n padding-left: 0; // Override default ul/ol\n list-style: none;\n &:extend(.clearfix all);\n\n > li {\n position: relative;\n display: block;\n\n > a {\n position: relative;\n display: block;\n padding: @nav-link-padding;\n &:hover,\n &:focus {\n text-decoration: none;\n background-color: @nav-link-hover-bg;\n }\n }\n\n // Disabled state sets text to gray and nukes hover/tab effects\n &.disabled > a {\n color: @nav-disabled-link-color;\n\n &:hover,\n &:focus {\n color: @nav-disabled-link-hover-color;\n text-decoration: none;\n background-color: transparent;\n cursor: @cursor-disabled;\n }\n }\n }\n\n // Open dropdowns\n .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @nav-link-hover-bg;\n border-color: @link-color;\n }\n }\n\n // Nav dividers (deprecated with v3.0.1)\n //\n // This should have been removed in v3 with the dropping of `.nav-list`, but\n // we missed it. We don't currently support this anywhere, but in the interest\n // of maintaining backward compatibility in case you use it, it's deprecated.\n .nav-divider {\n .nav-divider();\n }\n\n // Prevent IE8 from misplacing imgs\n //\n // See https://github.com/h5bp/html5-boilerplate/issues/984#issuecomment-3985989\n > li > a > img {\n max-width: none;\n }\n}\n\n\n// Tabs\n// -------------------------\n\n// Give the tabs something to sit on\n.nav-tabs {\n border-bottom: 1px solid @nav-tabs-border-color;\n > li {\n float: left;\n // Make the list-items overlay the bottom border\n margin-bottom: -1px;\n\n // Actual tabs (as links)\n > a {\n margin-right: 2px;\n line-height: @line-height-base;\n border: 1px solid transparent;\n border-radius: @border-radius-base @border-radius-base 0 0;\n &:hover {\n border-color: @nav-tabs-link-hover-border-color @nav-tabs-link-hover-border-color @nav-tabs-border-color;\n }\n }\n\n // Active state, and its :hover to override normal :hover\n &.active > a {\n &,\n &:hover,\n &:focus {\n color: @nav-tabs-active-link-hover-color;\n background-color: @nav-tabs-active-link-hover-bg;\n border: 1px solid @nav-tabs-active-link-hover-border-color;\n border-bottom-color: transparent;\n cursor: default;\n }\n }\n }\n // pulling this in mainly for less shorthand\n &.nav-justified {\n .nav-justified();\n .nav-tabs-justified();\n }\n}\n\n\n// Pills\n// -------------------------\n.nav-pills {\n > li {\n float: left;\n\n // Links rendered as pills\n > a {\n border-radius: @nav-pills-border-radius;\n }\n + li {\n margin-left: 2px;\n }\n\n // Active state\n &.active > a {\n &,\n &:hover,\n &:focus {\n color: @nav-pills-active-link-hover-color;\n background-color: @nav-pills-active-link-hover-bg;\n }\n }\n }\n}\n\n\n// Stacked pills\n.nav-stacked {\n > li {\n float: none;\n + li {\n margin-top: 2px;\n margin-left: 0; // no need for this gap between nav items\n }\n }\n}\n\n\n// Nav variations\n// --------------------------------------------------\n\n// Justified nav links\n// -------------------------\n\n.nav-justified {\n width: 100%;\n\n > li {\n float: none;\n > a {\n text-align: center;\n margin-bottom: 5px;\n }\n }\n\n > .dropdown .dropdown-menu {\n top: auto;\n left: auto;\n }\n\n @media (min-width: @screen-sm-min) {\n > li {\n display: table-cell;\n width: 1%;\n > a {\n margin-bottom: 0;\n }\n }\n }\n}\n\n// Move borders to anchors instead of bottom of list\n//\n// Mixin for adding on top the shared `.nav-justified` styles for our tabs\n.nav-tabs-justified {\n border-bottom: 0;\n\n > li > a {\n // Override margin from .nav-tabs\n margin-right: 0;\n border-radius: @border-radius-base;\n }\n\n > .active > a,\n > .active > a:hover,\n > .active > a:focus {\n border: 1px solid @nav-tabs-justified-link-border-color;\n }\n\n @media (min-width: @screen-sm-min) {\n > li > a {\n border-bottom: 1px solid @nav-tabs-justified-link-border-color;\n border-radius: @border-radius-base @border-radius-base 0 0;\n }\n > .active > a,\n > .active > a:hover,\n > .active > a:focus {\n border-bottom-color: @nav-tabs-justified-active-link-border-color;\n }\n }\n}\n\n\n// Tabbable tabs\n// -------------------------\n\n// Hide tabbable panes to start, show them when `.active`\n.tab-content {\n > .tab-pane {\n display: none;\n }\n > .active {\n display: block;\n }\n}\n\n\n// Dropdowns\n// -------------------------\n\n// Specific dropdowns\n.nav-tabs .dropdown-menu {\n // make dropdown border overlap tab border\n margin-top: -1px;\n // Remove the top rounded corners here since there is a hard edge above the menu\n .border-top-radius(0);\n}\n","//\n// Navbars\n// --------------------------------------------------\n\n\n// Wrapper and base class\n//\n// Provide a static navbar from which we expand to create full-width, fixed, and\n// other navbar variations.\n\n.navbar {\n position: relative;\n min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode)\n margin-bottom: @navbar-margin-bottom;\n border: 1px solid transparent;\n\n // Prevent floats from breaking the navbar\n &:extend(.clearfix all);\n\n @media (min-width: @grid-float-breakpoint) {\n border-radius: @navbar-border-radius;\n }\n}\n\n\n// Navbar heading\n//\n// Groups `.navbar-brand` and `.navbar-toggle` into a single component for easy\n// styling of responsive aspects.\n\n.navbar-header {\n &:extend(.clearfix all);\n\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n }\n}\n\n\n// Navbar collapse (body)\n//\n// Group your navbar content into this for easy collapsing and expanding across\n// various device sizes. By default, this content is collapsed when <768px, but\n// will expand past that for a horizontal display.\n//\n// To start (on mobile devices) the navbar links, forms, and buttons are stacked\n// vertically and include a `max-height` to overflow in case you have too much\n// content for the user's viewport.\n\n.navbar-collapse {\n overflow-x: visible;\n padding-right: @navbar-padding-horizontal;\n padding-left: @navbar-padding-horizontal;\n border-top: 1px solid transparent;\n box-shadow: inset 0 1px 0 rgba(255,255,255,.1);\n &:extend(.clearfix all);\n -webkit-overflow-scrolling: touch;\n\n &.in {\n overflow-y: auto;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n width: auto;\n border-top: 0;\n box-shadow: none;\n\n &.collapse {\n display: block !important;\n height: auto !important;\n padding-bottom: 0; // Override default setting\n overflow: visible !important;\n }\n\n &.in {\n overflow-y: visible;\n }\n\n // Undo the collapse side padding for navbars with containers to ensure\n // alignment of right-aligned contents.\n .navbar-fixed-top &,\n .navbar-static-top &,\n .navbar-fixed-bottom & {\n padding-left: 0;\n padding-right: 0;\n }\n }\n}\n\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n .navbar-collapse {\n max-height: @navbar-collapse-max-height;\n\n @media (max-device-width: @screen-xs-min) and (orientation: landscape) {\n max-height: 200px;\n }\n }\n}\n\n\n// Both navbar header and collapse\n//\n// When a container is present, change the behavior of the header and collapse.\n\n.container,\n.container-fluid {\n > .navbar-header,\n > .navbar-collapse {\n margin-right: -@navbar-padding-horizontal;\n margin-left: -@navbar-padding-horizontal;\n\n @media (min-width: @grid-float-breakpoint) {\n margin-right: 0;\n margin-left: 0;\n }\n }\n}\n\n\n//\n// Navbar alignment options\n//\n// Display the navbar across the entirety of the page or fixed it to the top or\n// bottom of the page.\n\n// Static top (unfixed, but 100% wide) navbar\n.navbar-static-top {\n z-index: @zindex-navbar;\n border-width: 0 0 1px;\n\n @media (min-width: @grid-float-breakpoint) {\n border-radius: 0;\n }\n}\n\n// Fix the top/bottom navbars when screen real estate supports it\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n position: fixed;\n right: 0;\n left: 0;\n z-index: @zindex-navbar-fixed;\n\n // Undo the rounded corners\n @media (min-width: @grid-float-breakpoint) {\n border-radius: 0;\n }\n}\n.navbar-fixed-top {\n top: 0;\n border-width: 0 0 1px;\n}\n.navbar-fixed-bottom {\n bottom: 0;\n margin-bottom: 0; // override .navbar defaults\n border-width: 1px 0 0;\n}\n\n\n// Brand/project name\n\n.navbar-brand {\n float: left;\n padding: @navbar-padding-vertical @navbar-padding-horizontal;\n font-size: @font-size-large;\n line-height: @line-height-computed;\n height: @navbar-height;\n\n &:hover,\n &:focus {\n text-decoration: none;\n }\n\n > img {\n display: block;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n .navbar > .container &,\n .navbar > .container-fluid & {\n margin-left: -@navbar-padding-horizontal;\n }\n }\n}\n\n\n// Navbar toggle\n//\n// Custom button for toggling the `.navbar-collapse`, powered by the collapse\n// JavaScript plugin.\n\n.navbar-toggle {\n position: relative;\n float: right;\n margin-right: @navbar-padding-horizontal;\n padding: 9px 10px;\n .navbar-vertical-align(34px);\n background-color: transparent;\n background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214\n border: 1px solid transparent;\n border-radius: @border-radius-base;\n\n // We remove the `outline` here, but later compensate by attaching `:hover`\n // styles to `:focus`.\n &:focus {\n outline: 0;\n }\n\n // Bars\n .icon-bar {\n display: block;\n width: 22px;\n height: 2px;\n border-radius: 1px;\n }\n .icon-bar + .icon-bar {\n margin-top: 4px;\n }\n\n @media (min-width: @grid-float-breakpoint) {\n display: none;\n }\n}\n\n\n// Navbar nav links\n//\n// Builds on top of the `.nav` components with its own modifier class to make\n// the nav the full height of the horizontal nav (above 768px).\n\n.navbar-nav {\n margin: (@navbar-padding-vertical / 2) -@navbar-padding-horizontal;\n\n > li > a {\n padding-top: 10px;\n padding-bottom: 10px;\n line-height: @line-height-computed;\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display when collapsed\n .open .dropdown-menu {\n position: static;\n float: none;\n width: auto;\n margin-top: 0;\n background-color: transparent;\n border: 0;\n box-shadow: none;\n > li > a,\n .dropdown-header {\n padding: 5px 15px 5px 25px;\n }\n > li > a {\n line-height: @line-height-computed;\n &:hover,\n &:focus {\n background-image: none;\n }\n }\n }\n }\n\n // Uncollapse the nav\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n margin: 0;\n\n > li {\n float: left;\n > a {\n padding-top: @navbar-padding-vertical;\n padding-bottom: @navbar-padding-vertical;\n }\n }\n }\n}\n\n\n// Navbar form\n//\n// Extension of the `.form-inline` with some extra flavor for optimum display in\n// our navbars.\n\n.navbar-form {\n margin-left: -@navbar-padding-horizontal;\n margin-right: -@navbar-padding-horizontal;\n padding: 10px @navbar-padding-horizontal;\n border-top: 1px solid transparent;\n border-bottom: 1px solid transparent;\n @shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n\n // Mixin behavior for optimum display\n .form-inline();\n\n .form-group {\n @media (max-width: @grid-float-breakpoint-max) {\n margin-bottom: 5px;\n\n &:last-child {\n margin-bottom: 0;\n }\n }\n }\n\n // Vertically center in expanded, horizontal navbar\n .navbar-vertical-align(@input-height-base);\n\n // Undo 100% width for pull classes\n @media (min-width: @grid-float-breakpoint) {\n width: auto;\n border: 0;\n margin-left: 0;\n margin-right: 0;\n padding-top: 0;\n padding-bottom: 0;\n .box-shadow(none);\n }\n}\n\n\n// Dropdown menus\n\n// Menu position and menu carets\n.navbar-nav > li > .dropdown-menu {\n margin-top: 0;\n .border-top-radius(0);\n}\n// Menu position and menu caret support for dropups via extra dropup class\n.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu {\n margin-bottom: 0;\n .border-top-radius(@navbar-border-radius);\n .border-bottom-radius(0);\n}\n\n\n// Buttons in navbars\n//\n// Vertically center a button within a navbar (when *not* in a form).\n\n.navbar-btn {\n .navbar-vertical-align(@input-height-base);\n\n &.btn-sm {\n .navbar-vertical-align(@input-height-small);\n }\n &.btn-xs {\n .navbar-vertical-align(22);\n }\n}\n\n\n// Text in navbars\n//\n// Add a class to make any element properly align itself vertically within the navbars.\n\n.navbar-text {\n .navbar-vertical-align(@line-height-computed);\n\n @media (min-width: @grid-float-breakpoint) {\n float: left;\n margin-left: @navbar-padding-horizontal;\n margin-right: @navbar-padding-horizontal;\n }\n}\n\n\n// Component alignment\n//\n// Repurpose the pull utilities as their own navbar utilities to avoid specificity\n// issues with parents and chaining. Only do this when the navbar is uncollapsed\n// though so that navbar contents properly stack and align in mobile.\n//\n// Declared after the navbar components to ensure more specificity on the margins.\n\n@media (min-width: @grid-float-breakpoint) {\n .navbar-left { .pull-left(); }\n .navbar-right {\n .pull-right();\n margin-right: -@navbar-padding-horizontal;\n\n ~ .navbar-right {\n margin-right: 0;\n }\n }\n}\n\n\n// Alternate navbars\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n background-color: @navbar-default-bg;\n border-color: @navbar-default-border;\n\n .navbar-brand {\n color: @navbar-default-brand-color;\n &:hover,\n &:focus {\n color: @navbar-default-brand-hover-color;\n background-color: @navbar-default-brand-hover-bg;\n }\n }\n\n .navbar-text {\n color: @navbar-default-color;\n }\n\n .navbar-nav {\n > li > a {\n color: @navbar-default-link-color;\n\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n background-color: @navbar-default-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-active-color;\n background-color: @navbar-default-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n background-color: @navbar-default-link-disabled-bg;\n }\n }\n }\n\n .navbar-toggle {\n border-color: @navbar-default-toggle-border-color;\n &:hover,\n &:focus {\n background-color: @navbar-default-toggle-hover-bg;\n }\n .icon-bar {\n background-color: @navbar-default-toggle-icon-bar-bg;\n }\n }\n\n .navbar-collapse,\n .navbar-form {\n border-color: @navbar-default-border;\n }\n\n // Dropdown menu items\n .navbar-nav {\n // Remove background color from open dropdown\n > .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @navbar-default-link-active-bg;\n color: @navbar-default-link-active-color;\n }\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display when collapsed\n .open .dropdown-menu {\n > li > a {\n color: @navbar-default-link-color;\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n background-color: @navbar-default-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-active-color;\n background-color: @navbar-default-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n background-color: @navbar-default-link-disabled-bg;\n }\n }\n }\n }\n }\n\n\n // Links in navbars\n //\n // Add a class to ensure links outside the navbar nav are colored correctly.\n\n .navbar-link {\n color: @navbar-default-link-color;\n &:hover {\n color: @navbar-default-link-hover-color;\n }\n }\n\n .btn-link {\n color: @navbar-default-link-color;\n &:hover,\n &:focus {\n color: @navbar-default-link-hover-color;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @navbar-default-link-disabled-color;\n }\n }\n }\n}\n\n// Inverse navbar\n\n.navbar-inverse {\n background-color: @navbar-inverse-bg;\n border-color: @navbar-inverse-border;\n\n .navbar-brand {\n color: @navbar-inverse-brand-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-brand-hover-color;\n background-color: @navbar-inverse-brand-hover-bg;\n }\n }\n\n .navbar-text {\n color: @navbar-inverse-color;\n }\n\n .navbar-nav {\n > li > a {\n color: @navbar-inverse-link-color;\n\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n background-color: @navbar-inverse-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-active-color;\n background-color: @navbar-inverse-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n background-color: @navbar-inverse-link-disabled-bg;\n }\n }\n }\n\n // Darken the responsive nav toggle\n .navbar-toggle {\n border-color: @navbar-inverse-toggle-border-color;\n &:hover,\n &:focus {\n background-color: @navbar-inverse-toggle-hover-bg;\n }\n .icon-bar {\n background-color: @navbar-inverse-toggle-icon-bar-bg;\n }\n }\n\n .navbar-collapse,\n .navbar-form {\n border-color: darken(@navbar-inverse-bg, 7%);\n }\n\n // Dropdowns\n .navbar-nav {\n > .open > a {\n &,\n &:hover,\n &:focus {\n background-color: @navbar-inverse-link-active-bg;\n color: @navbar-inverse-link-active-color;\n }\n }\n\n @media (max-width: @grid-float-breakpoint-max) {\n // Dropdowns get custom display\n .open .dropdown-menu {\n > .dropdown-header {\n border-color: @navbar-inverse-border;\n }\n .divider {\n background-color: @navbar-inverse-border;\n }\n > li > a {\n color: @navbar-inverse-link-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n background-color: @navbar-inverse-link-hover-bg;\n }\n }\n > .active > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-active-color;\n background-color: @navbar-inverse-link-active-bg;\n }\n }\n > .disabled > a {\n &,\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n background-color: @navbar-inverse-link-disabled-bg;\n }\n }\n }\n }\n }\n\n .navbar-link {\n color: @navbar-inverse-link-color;\n &:hover {\n color: @navbar-inverse-link-hover-color;\n }\n }\n\n .btn-link {\n color: @navbar-inverse-link-color;\n &:hover,\n &:focus {\n color: @navbar-inverse-link-hover-color;\n }\n &[disabled],\n fieldset[disabled] & {\n &:hover,\n &:focus {\n color: @navbar-inverse-link-disabled-color;\n }\n }\n }\n}\n","// Navbar vertical align\n//\n// Vertically center elements in the navbar.\n// Example: an element has a height of 30px, so write out `.navbar-vertical-align(30px);` to calculate the appropriate top margin.\n\n.navbar-vertical-align(@element-height) {\n margin-top: ((@navbar-height - @element-height) / 2);\n margin-bottom: ((@navbar-height - @element-height) / 2);\n}\n","//\n// Utility classes\n// --------------------------------------------------\n\n\n// Floats\n// -------------------------\n\n.clearfix {\n .clearfix();\n}\n.center-block {\n .center-block();\n}\n.pull-right {\n float: right !important;\n}\n.pull-left {\n float: left !important;\n}\n\n\n// Toggling content\n// -------------------------\n\n// Note: Deprecated .hide in favor of .hidden or .sr-only (as appropriate) in v3.0.1\n.hide {\n display: none !important;\n}\n.show {\n display: block !important;\n}\n.invisible {\n visibility: hidden;\n}\n.text-hide {\n .text-hide();\n}\n\n\n// Hide from screenreaders and browsers\n//\n// Credit: HTML5 Boilerplate\n\n.hidden {\n display: none !important;\n}\n\n\n// For Affix plugin\n// -------------------------\n\n.affix {\n position: fixed;\n}\n","//\n// Breadcrumbs\n// --------------------------------------------------\n\n\n.breadcrumb {\n padding: @breadcrumb-padding-vertical @breadcrumb-padding-horizontal;\n margin-bottom: @line-height-computed;\n list-style: none;\n background-color: @breadcrumb-bg;\n border-radius: @border-radius-base;\n\n > li {\n display: inline-block;\n\n + li:before {\n content: \"@{breadcrumb-separator}\\00a0\"; // Unicode space added since inline-block means non-collapsing white-space\n padding: 0 5px;\n color: @breadcrumb-color;\n }\n }\n\n > .active {\n color: @breadcrumb-active-color;\n }\n}\n","//\n// Pagination (multiple pages)\n// --------------------------------------------------\n.pagination {\n display: inline-block;\n padding-left: 0;\n margin: @line-height-computed 0;\n border-radius: @border-radius-base;\n\n > li {\n display: inline; // Remove list-style and block-level defaults\n > a,\n > span {\n position: relative;\n float: left; // Collapse white-space\n padding: @padding-base-vertical @padding-base-horizontal;\n line-height: @line-height-base;\n text-decoration: none;\n color: @pagination-color;\n background-color: @pagination-bg;\n border: 1px solid @pagination-border;\n margin-left: -1px;\n }\n &:first-child {\n > a,\n > span {\n margin-left: 0;\n .border-left-radius(@border-radius-base);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius-base);\n }\n }\n }\n\n > li > a,\n > li > span {\n &:hover,\n &:focus {\n z-index: 2;\n color: @pagination-hover-color;\n background-color: @pagination-hover-bg;\n border-color: @pagination-hover-border;\n }\n }\n\n > .active > a,\n > .active > span {\n &,\n &:hover,\n &:focus {\n z-index: 3;\n color: @pagination-active-color;\n background-color: @pagination-active-bg;\n border-color: @pagination-active-border;\n cursor: default;\n }\n }\n\n > .disabled {\n > span,\n > span:hover,\n > span:focus,\n > a,\n > a:hover,\n > a:focus {\n color: @pagination-disabled-color;\n background-color: @pagination-disabled-bg;\n border-color: @pagination-disabled-border;\n cursor: @cursor-disabled;\n }\n }\n}\n\n// Sizing\n// --------------------------------------------------\n\n// Large\n.pagination-lg {\n .pagination-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @border-radius-large);\n}\n\n// Small\n.pagination-sm {\n .pagination-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @border-radius-small);\n}\n","// Pagination\n\n.pagination-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) {\n > li {\n > a,\n > span {\n padding: @padding-vertical @padding-horizontal;\n font-size: @font-size;\n line-height: @line-height;\n }\n &:first-child {\n > a,\n > span {\n .border-left-radius(@border-radius);\n }\n }\n &:last-child {\n > a,\n > span {\n .border-right-radius(@border-radius);\n }\n }\n }\n}\n","//\n// Pager pagination\n// --------------------------------------------------\n\n\n.pager {\n padding-left: 0;\n margin: @line-height-computed 0;\n list-style: none;\n text-align: center;\n &:extend(.clearfix all);\n li {\n display: inline;\n > a,\n > span {\n display: inline-block;\n padding: 5px 14px;\n background-color: @pager-bg;\n border: 1px solid @pager-border;\n border-radius: @pager-border-radius;\n }\n\n > a:hover,\n > a:focus {\n text-decoration: none;\n background-color: @pager-hover-bg;\n }\n }\n\n .next {\n > a,\n > span {\n float: right;\n }\n }\n\n .previous {\n > a,\n > span {\n float: left;\n }\n }\n\n .disabled {\n > a,\n > a:hover,\n > a:focus,\n > span {\n color: @pager-disabled-color;\n background-color: @pager-bg;\n cursor: @cursor-disabled;\n }\n }\n}\n","//\n// Labels\n// --------------------------------------------------\n\n.label {\n display: inline;\n padding: .2em .6em .3em;\n font-size: 75%;\n font-weight: bold;\n line-height: 1;\n color: @label-color;\n text-align: center;\n white-space: nowrap;\n vertical-align: baseline;\n border-radius: .25em;\n\n // Add hover effects, but only for links\n a& {\n &:hover,\n &:focus {\n color: @label-link-hover-color;\n text-decoration: none;\n cursor: pointer;\n }\n }\n\n // Empty labels collapse automatically (not available in IE8)\n &:empty {\n display: none;\n }\n\n // Quick fix for labels in buttons\n .btn & {\n position: relative;\n top: -1px;\n }\n}\n\n// Colors\n// Contextual variations (linked labels get darker on :hover)\n\n.label-default {\n .label-variant(@label-default-bg);\n}\n\n.label-primary {\n .label-variant(@label-primary-bg);\n}\n\n.label-success {\n .label-variant(@label-success-bg);\n}\n\n.label-info {\n .label-variant(@label-info-bg);\n}\n\n.label-warning {\n .label-variant(@label-warning-bg);\n}\n\n.label-danger {\n .label-variant(@label-danger-bg);\n}\n","// Labels\n\n.label-variant(@color) {\n background-color: @color;\n\n &[href] {\n &:hover,\n &:focus {\n background-color: darken(@color, 10%);\n }\n }\n}\n","//\n// Badges\n// --------------------------------------------------\n\n\n// Base class\n.badge {\n display: inline-block;\n min-width: 10px;\n padding: 3px 7px;\n font-size: @font-size-small;\n font-weight: @badge-font-weight;\n color: @badge-color;\n line-height: @badge-line-height;\n vertical-align: middle;\n white-space: nowrap;\n text-align: center;\n background-color: @badge-bg;\n border-radius: @badge-border-radius;\n\n // Empty badges collapse automatically (not available in IE8)\n &:empty {\n display: none;\n }\n\n // Quick fix for badges in buttons\n .btn & {\n position: relative;\n top: -1px;\n }\n\n .btn-xs &,\n .btn-group-xs > .btn & {\n top: 0;\n padding: 1px 5px;\n }\n\n // Hover state, but only for links\n a& {\n &:hover,\n &:focus {\n color: @badge-link-hover-color;\n text-decoration: none;\n cursor: pointer;\n }\n }\n\n // Account for badges in navs\n .list-group-item.active > &,\n .nav-pills > .active > a > & {\n color: @badge-active-color;\n background-color: @badge-active-bg;\n }\n\n .list-group-item > & {\n float: right;\n }\n\n .list-group-item > & + & {\n margin-right: 5px;\n }\n\n .nav-pills > li > a > & {\n margin-left: 3px;\n }\n}\n","//\n// Jumbotron\n// --------------------------------------------------\n\n\n.jumbotron {\n padding-top: @jumbotron-padding;\n padding-bottom: @jumbotron-padding;\n margin-bottom: @jumbotron-padding;\n color: @jumbotron-color;\n background-color: @jumbotron-bg;\n\n h1,\n .h1 {\n color: @jumbotron-heading-color;\n }\n\n p {\n margin-bottom: (@jumbotron-padding / 2);\n font-size: @jumbotron-font-size;\n font-weight: 200;\n }\n\n > hr {\n border-top-color: darken(@jumbotron-bg, 10%);\n }\n\n .container &,\n .container-fluid & {\n border-radius: @border-radius-large; // Only round corners at higher resolutions if contained in a container\n padding-left: (@grid-gutter-width / 2);\n padding-right: (@grid-gutter-width / 2);\n }\n\n .container {\n max-width: 100%;\n }\n\n @media screen and (min-width: @screen-sm-min) {\n padding-top: (@jumbotron-padding * 1.6);\n padding-bottom: (@jumbotron-padding * 1.6);\n\n .container &,\n .container-fluid & {\n padding-left: (@jumbotron-padding * 2);\n padding-right: (@jumbotron-padding * 2);\n }\n\n h1,\n .h1 {\n font-size: @jumbotron-heading-font-size;\n }\n }\n}\n","//\n// Thumbnails\n// --------------------------------------------------\n\n\n// Mixin and adjust the regular image class\n.thumbnail {\n display: block;\n padding: @thumbnail-padding;\n margin-bottom: @line-height-computed;\n line-height: @line-height-base;\n background-color: @thumbnail-bg;\n border: 1px solid @thumbnail-border;\n border-radius: @thumbnail-border-radius;\n .transition(border .2s ease-in-out);\n\n > img,\n a > img {\n &:extend(.img-responsive);\n margin-left: auto;\n margin-right: auto;\n }\n\n // Add a hover state for linked versions only\n a&:hover,\n a&:focus,\n a&.active {\n border-color: @link-color;\n }\n\n // Image captions\n .caption {\n padding: @thumbnail-caption-padding;\n color: @thumbnail-caption-color;\n }\n}\n","//\n// Alerts\n// --------------------------------------------------\n\n\n// Base styles\n// -------------------------\n\n.alert {\n padding: @alert-padding;\n margin-bottom: @line-height-computed;\n border: 1px solid transparent;\n border-radius: @alert-border-radius;\n\n // Headings for larger alerts\n h4 {\n margin-top: 0;\n // Specified for the h4 to prevent conflicts of changing @headings-color\n color: inherit;\n }\n\n // Provide class for links that match alerts\n .alert-link {\n font-weight: @alert-link-font-weight;\n }\n\n // Improve alignment and spacing of inner content\n > p,\n > ul {\n margin-bottom: 0;\n }\n\n > p + p {\n margin-top: 5px;\n }\n}\n\n// Dismissible alerts\n//\n// Expand the right padding and account for the close button's positioning.\n\n.alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0.\n.alert-dismissible {\n padding-right: (@alert-padding + 20);\n\n // Adjust close link position\n .close {\n position: relative;\n top: -2px;\n right: -21px;\n color: inherit;\n }\n}\n\n// Alternate styles\n//\n// Generate contextual modifier classes for colorizing the alert.\n\n.alert-success {\n .alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text);\n}\n\n.alert-info {\n .alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text);\n}\n\n.alert-warning {\n .alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text);\n}\n\n.alert-danger {\n .alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text);\n}\n","// Alerts\n\n.alert-variant(@background; @border; @text-color) {\n background-color: @background;\n border-color: @border;\n color: @text-color;\n\n hr {\n border-top-color: darken(@border, 5%);\n }\n .alert-link {\n color: darken(@text-color, 10%);\n }\n}\n","//\n// Progress bars\n// --------------------------------------------------\n\n\n// Bar animations\n// -------------------------\n\n// WebKit\n@-webkit-keyframes progress-bar-stripes {\n from { background-position: 40px 0; }\n to { background-position: 0 0; }\n}\n\n// Spec and IE10+\n@keyframes progress-bar-stripes {\n from { background-position: 40px 0; }\n to { background-position: 0 0; }\n}\n\n\n// Bar itself\n// -------------------------\n\n// Outer container\n.progress {\n overflow: hidden;\n height: @line-height-computed;\n margin-bottom: @line-height-computed;\n background-color: @progress-bg;\n border-radius: @progress-border-radius;\n .box-shadow(inset 0 1px 2px rgba(0,0,0,.1));\n}\n\n// Bar of progress\n.progress-bar {\n float: left;\n width: 0%;\n height: 100%;\n font-size: @font-size-small;\n line-height: @line-height-computed;\n color: @progress-bar-color;\n text-align: center;\n background-color: @progress-bar-bg;\n .box-shadow(inset 0 -1px 0 rgba(0,0,0,.15));\n .transition(width .6s ease);\n}\n\n// Striped bars\n//\n// `.progress-striped .progress-bar` is deprecated as of v3.2.0 in favor of the\n// `.progress-bar-striped` class, which you just add to an existing\n// `.progress-bar`.\n.progress-striped .progress-bar,\n.progress-bar-striped {\n #gradient > .striped();\n background-size: 40px 40px;\n}\n\n// Call animation for the active one\n//\n// `.progress.active .progress-bar` is deprecated as of v3.2.0 in favor of the\n// `.progress-bar.active` approach.\n.progress.active .progress-bar,\n.progress-bar.active {\n .animation(progress-bar-stripes 2s linear infinite);\n}\n\n\n// Variations\n// -------------------------\n\n.progress-bar-success {\n .progress-bar-variant(@progress-bar-success-bg);\n}\n\n.progress-bar-info {\n .progress-bar-variant(@progress-bar-info-bg);\n}\n\n.progress-bar-warning {\n .progress-bar-variant(@progress-bar-warning-bg);\n}\n\n.progress-bar-danger {\n .progress-bar-variant(@progress-bar-danger-bg);\n}\n","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Progress bars\n\n.progress-bar-variant(@color) {\n background-color: @color;\n\n // Deprecated parent class requirement as of v3.2.0\n .progress-striped & {\n #gradient > .striped();\n }\n}\n",".media {\n // Proper spacing between instances of .media\n margin-top: 15px;\n\n &:first-child {\n margin-top: 0;\n }\n}\n\n.media,\n.media-body {\n zoom: 1;\n overflow: hidden;\n}\n\n.media-body {\n width: 10000px;\n}\n\n.media-object {\n display: block;\n\n // Fix collapse in webkit from max-width: 100% and display: table-cell.\n &.img-thumbnail {\n max-width: none;\n }\n}\n\n.media-right,\n.media > .pull-right {\n padding-left: 10px;\n}\n\n.media-left,\n.media > .pull-left {\n padding-right: 10px;\n}\n\n.media-left,\n.media-right,\n.media-body {\n display: table-cell;\n vertical-align: top;\n}\n\n.media-middle {\n vertical-align: middle;\n}\n\n.media-bottom {\n vertical-align: bottom;\n}\n\n// Reset margins on headings for tighter default spacing\n.media-heading {\n margin-top: 0;\n margin-bottom: 5px;\n}\n\n// Media list variation\n//\n// Undo default ul/ol styles\n.media-list {\n padding-left: 0;\n list-style: none;\n}\n","//\n// List groups\n// --------------------------------------------------\n\n\n// Base class\n//\n// Easily usable on