Compare commits

...

126 Commits

Author SHA1 Message Date
Jeremy Stretch
a355783377 Merge pull request #1316 from digitalocean/develop
Release v2.0.8
2017-07-05 14:36:08 -04:00
Jeremy Stretch
dafdbc9ddb Release v2.0.8 2017-07-05 14:34:46 -04:00
Jeremy Stretch
14f5204548 Fixes #1289: Retain inside NAT assignment when editing an IP address 2017-07-05 14:29:40 -04:00
Jeremy Stretch
5233463f0b Merge pull request #1315 from s11-charendt/develop
Preserve fileextension, regardless of upper or lower case on imageupload
2017-07-05 11:02:18 -04:00
Jeremy Stretch
1d4a416100 Fixes #1297: Allow passing custom field choice selection PKs as string-quoted integers 2017-07-05 11:00:43 -04:00
Jeremy Stretch
25ee796d5b Include instructions when displaying an inactive user key 2017-07-05 10:51:25 -04:00
Christian Harendt
e08107063a Preserve fileextension, regardless of upper or lower case on imageuploads 2017-07-04 14:26:35 +02:00
Jeremy Stretch
cd5a86bfcf Closes #1303: Highlight installed interface connections in green on device view 2017-06-29 13:35:54 -04:00
Jeremy Stretch
97b67d0f93 Fixes #1299: Corrected permission to add a service to a device 2017-06-28 12:05:26 -04:00
Jeremy Stretch
3f82be7192 Closes #1298: Calculate prefix utilization based on its status (container or non-container) 2017-06-26 17:36:24 -04:00
Jeremy Stretch
adfcb5f7b6 Fixes #1295: Docstring typo 2017-06-26 09:31:20 -04:00
Jeremy Stretch
5aba1d9aec Fixes #1288: Corrected permission name for deleting image attachments 2017-06-19 09:20:03 -04:00
Jeremy Stretch
afdf5750b5 Fixes #1279: Fix primary_ip assignment during IP address import 2017-06-16 12:45:42 -04:00
Jeremy Stretch
ea869d4ffc Fixes #1282: Fixed tooltips on "mark connected/planned" toggle buttons for device connections 2017-06-16 10:02:14 -04:00
Jeremy Stretch
9d89eed873 Fixes #1281: Show LLDP neighbors tab on device view only if necessary conditions are met 2017-06-16 09:32:58 -04:00
Jeremy Stretch
c00eea7991 Post-release version bump 2017-06-15 14:28:35 -04:00
Jeremy Stretch
88239e0b0d Merge pull request #1278 from digitalocean/develop
Release v2.0.7
2017-06-15 14:26:38 -04:00
Jeremy Stretch
9930e2745f Release v2.0.7 2017-06-15 14:22:16 -04:00
Jeremy Stretch
da3879e928 Fixes #1275: Raise validation error on prefix import when multiple VLANs are found 2017-06-15 14:13:20 -04:00
Jeremy Stretch
7195b7c803 Closes #626: Added bulk disconnect function for console/power/interface connections on device view 2017-06-15 14:01:49 -04:00
Jeremy Stretch
9b082eea14 Fixes #1274: Exclude unterminated circuits from topology maps 2017-06-15 10:05:14 -04:00
Jeremy Stretch
a16218b311 Fixes #1273: Corrected status choices in IP address import form 2017-06-14 16:22:49 -04:00
Jeremy Stretch
29a71fd903 #1265: Improved livesearch UI Javascript 2017-06-14 14:50:12 -04:00
Jeremy Stretch
fcacac7c6f Fixes #1265: Fix console/power/interface connection validation when selecting a device via live search 2017-06-14 13:00:36 -04:00
Jeremy Stretch
78d74261e9 Fixes #1266: Prevent termination a circuit to an already-connected interface 2017-06-14 10:57:43 -04:00
Jeremy Stretch
16d694734b Fixes #1268: Fix CSV import error under Python 3 2017-06-14 09:55:52 -04:00
Jeremy Stretch
252ab0fbab Fixes #1238: Fix error when editing an IP with a NAT assignment which has no assigned device 2017-06-13 16:57:25 -04:00
Jeremy Stretch
8eb9c451a1 Renamed AddViews to CreateViews for consistency 2017-06-13 16:48:21 -04:00
Jeremy Stretch
469c52be28 Fixes #1263: Differentiate add and edit permissions for objects 2017-06-13 16:41:57 -04:00
Jeremy Stretch
54fa51eeff Post-release version bump 2017-06-13 15:55:58 -04:00
Jeremy Stretch
5456af6867 Removed 'update-alternatives' from Python3 instructions 2017-06-13 14:28:38 -04:00
Jeremy Stretch
180446c34d Removed Debian/RHEL references from installation docs 2017-06-12 10:06:19 -04:00
Jeremy Stretch
5c63a499d5 Merge pull request #1259 from digitalocean/develop
Release v2.0.6
2017-06-12 09:51:15 -04:00
Jeremy Stretch
3a2c5b318a Release v2.0.6 2017-06-12 09:44:09 -04:00
Jeremy Stretch
cfff69a715 Closes #1180: Simplified the process of finding related devices when viewing a device 2017-06-09 17:04:09 -04:00
Jeremy Stretch
08883d86ef Closes #913: Added headers to object CSV exports 2017-06-09 16:24:59 -04:00
Jeremy Stretch
8a849ebeff Closes #990: Enable logging configuration in configuration.py 2017-06-09 15:03:10 -04:00
Jeremy Stretch
05a796faf1 Closes #704: Allow filtering VLANs by group when editing prefixes 2017-06-09 14:15:12 -04:00
Jeremy Stretch
9e1d03b383 Formatting cleanup 2017-06-09 12:19:32 -04:00
Jeremy Stretch
0a929f2971 Fixes #1253: Improved upgrade.sh to allow forcing Python2 2017-06-09 12:13:47 -04:00
Jeremy Stretch
7878992570 First stab at an interactive shell which pre-imports all models 2017-06-08 16:38:25 -04:00
Jeremy Stretch
4f95926cbd Added utilization percetange to aggregate and prefix views 2017-06-08 12:48:49 -04:00
Jeremy Stretch
f3e997ea39 Closes #40: Added IP utilization graph to prefix list 2017-06-08 12:37:25 -04:00
Jeremy Stretch
2b921c21ff Post-release version bump 2017-06-08 10:12:39 -04:00
Jeremy Stretch
50496b1a59 Merge pull request #1251 from digitalocean/develop
Release v2.0.5
2017-06-08 10:10:41 -04:00
Jeremy Stretch
9736d63577 Release v2.0.5 2017-06-08 10:05:26 -04:00
Jeremy Stretch
13add414c4 Fixed formatting and typos 2017-06-08 10:02:16 -04:00
Jeremy Stretch
b032bc13db Merge pull request #1080 from bellwood/patch-2
Enhance LDAP documentation
2017-06-08 09:55:17 -04:00
Jeremy Stretch
aaad428438 Merge pull request #1249 from feuerrot/patch-1
upgrading.md: fix typo
2017-06-08 09:17:52 -04:00
Markus Witt
203895fc7e upgrading.md: fix typo 2017-06-08 10:58:30 +02:00
Jeremy Stretch
aab1fab445 PEP8 fix 2017-06-07 15:56:59 -04:00
Jeremy Stretch
e06221bc89 Merge branch 'import_headers' into develop 2017-06-07 15:54:59 -04:00
Jeremy Stretch
26a13edcf3 Layout tweaks 2017-06-07 15:54:48 -04:00
Jeremy Stretch
65b6fe576f Converted device fields to use FlexibleModelChoiceField; misc cleanup 2017-06-07 15:51:11 -04:00
Jeremy Stretch
4671829ad8 Removed obsolete validation from InterfaceConnectionCSVForm 2017-06-07 15:33:10 -04:00
Jeremy Stretch
293be752ca Form cleanup and fixed child device import 2017-06-07 15:30:28 -04:00
Jeremy Stretch
0a6e4f31d5 Updated CSVForm validation 2017-06-07 14:19:08 -04:00
Jeremy Stretch
e6c4ce51f7 Replaced all CSVForm ChoiceFields with CSVChoiceField 2017-06-07 13:22:06 -04:00
Jeremy Stretch
3924063060 Converted ConnectionStatusCSVField to a ChoiceField 2017-06-06 22:35:41 -04:00
Jeremy Stretch
d122f9f700 Added dynamic examples for CSV form fields 2017-06-06 17:27:26 -04:00
Brian Ellwood
d0649ba815 Update ldap.md
Wrap code in code block
2017-06-05 20:37:09 -04:00
Brian Ellwood
1ec09270a7 Update ldap.md
Capitalization
2017-06-05 20:35:05 -04:00
Jeremy Stretch
1ddd7415cb Replaced old CSVDataField 2017-06-05 16:16:23 -04:00
Jeremy Stretch
ec9d0d4008 Implemented ConnectionStatusCSVField 2017-06-05 16:12:51 -04:00
Jeremy Stretch
08c8bd3049 Renamed new import view to BulkImportView 2017-06-05 15:53:41 -04:00
Jeremy Stretch
2520d9f400 Converted console/power import views to new scheme 2017-06-05 15:53:03 -04:00
Jeremy Stretch
0e863ff9ca Converted interface connections import view to new scheme 2017-06-05 15:04:23 -04:00
Jeremy Stretch
1b78f54c6b Merge pull request #1245 from digitalocean/fix-secret-device-filter
Fixes #1244
2017-06-03 00:03:27 -04:00
Zach Moody
b732c24ec4 Fixes #1244 2017-06-02 16:33:49 -05:00
Jeremy Stretch
af604aba31 Converted secrets import view to new scheme 2017-06-02 17:23:41 -04:00
Jeremy Stretch
c82658440f Converted IPAM import views to new scheme 2017-06-02 16:07:11 -04:00
Jeremy Stretch
7e660d4d8e Converted site/rack/device import views to new scheme 2017-06-02 14:49:25 -04:00
Jeremy Stretch
4a8147f8a5 Converted circuits import views to new scheme 2017-06-02 13:40:52 -04:00
Jeremy Stretch
583830c652 #1190: Allow partial string matching when searching on custom fields 2017-06-01 16:57:32 -04:00
Jeremy Stretch
95fdb549d7 Fixes #1243: Catch ValueError in IP-based object filters 2017-06-01 16:13:07 -04:00
Jeremy Stretch
a598f0e632 Initial work on #655: CSV import headers 2017-05-31 17:40:11 -04:00
Jeremy Stretch
293dbd8a8b Fixes #1226: Improve validation for custom field values submitted via the API 2017-05-31 14:09:57 -04:00
Jeremy Stretch
f03a378ce0 Fixes #1239: Fix server error when creating VLANGroup via API 2017-05-31 11:50:03 -04:00
Jeremy Stretch
6aae8aee5b Closes #1237: Enabled setting limit=0 to disable pagination in API requests; added MAX_PAGE_SIZE configuration setting 2017-05-30 23:24:21 -04:00
Jeremy Stretch
6d908d3e79 Fixes #1236: Truncate rack names in elevations list; add facility ID 2017-05-30 13:13:01 -04:00
Jeremy Stretch
d5016c7133 Fixes #1235: Fix permission name for adding/editing inventory items 2017-05-30 13:03:25 -04:00
Jeremy Stretch
b5a1b692bd Fixes #1225: Fixed border on empty circuits table on provider view 2017-05-26 10:08:03 -04:00
Jeremy Stretch
834c396a22 Tweaked upgrade script to prefer pip3/python3 if present 2017-05-26 09:55:22 -04:00
Jeremy Stretch
bc18d241e8 Post-release version bump 2017-05-25 14:46:34 -04:00
Jeremy Stretch
f7b0d22f86 Merge pull request #1230 from digitalocean/develop
Release v2.0.4
2017-05-25 14:45:13 -04:00
Jeremy Stretch
5a1877087f Release v2.0.4 2017-05-25 14:42:58 -04:00
Jeremy Stretch
50462ec15d Added notes to discourage the prepending of arbitrary tags to issue titles 2017-05-25 14:38:33 -04:00
Jeremy Stretch
1dd5e2c926 Fixes #1229: Fix validation error on forms where API search is used 2017-05-25 14:33:50 -04:00
Jeremy Stretch
ebddc46bc0 PEP8 fix 2017-05-24 14:22:37 -04:00
Jeremy Stretch
138cbf9761 Created migrations for transition to Unicode literals 2017-05-24 14:18:52 -04:00
Jeremy Stretch
f21c6bca00 Import unicode_literals 2017-05-24 11:33:11 -04:00
Jeremy Stretch
9aad8a7774 Fixes #1219: Fix image attachment URLs when BASE_PATH is set 2017-05-24 10:34:01 -04:00
Jeremy Stretch
68b6c7d886 Fixes #1210: Fix TemplateDoesNotExist errors on browsable API views 2017-05-24 09:40:24 -04:00
Jeremy Stretch
1c489e57cc Added a warning to note "untracked migrations" warnings during an upgrade 2017-05-23 22:36:40 -04:00
Jeremy Stretch
6719578f14 Fixes #1212: Allow assigning new VLANs to global VLAN groups 2017-05-23 22:23:50 -04:00
Jeremy Stretch
d5587de316 Fixes #1213: Corrected table header ordering links 2017-05-23 22:15:13 -04:00
Jeremy Stretch
77f28e3441 Fixes #1214: Add status to list of required fields on child device import form 2017-05-23 22:12:17 -04:00
Jeremy Stretch
3fa63b774e Converted home view to a CBV 2017-05-19 16:03:51 -04:00
Jeremy Stretch
713c7cd8e3 Cleaned up 500 error template 2017-05-19 16:03:04 -04:00
Jeremy Stretch
e6b4d87939 Converted all user views to CBVs 2017-05-19 15:47:19 -04:00
Jeremy Stretch
27c94d9874 Fixes #1206: Fix redirection in admin UI after activating secret keys when BASE_PATH is set 2017-05-19 13:23:17 -04:00
Jeremy Stretch
eece8a0e26 Fixes #1207: Include nested LAG serializer when showing interface connections (API) 2017-05-19 12:59:27 -04:00
Jeremy Stretch
fb85867d72 Converted all object views to class-based views 2017-05-18 17:00:57 -04:00
Jeremy Stretch
c454bfcd84 Fixed incorrect message 2017-05-18 14:53:35 -04:00
Jeremy Stretch
ad95b86fdd Merge pull request #1201 from digitalocean/develop
Release v2.0.3
2017-05-18 14:37:19 -04:00
Jeremy Stretch
769232f368 Post-release version bump 2017-05-18 14:32:11 -04:00
Jeremy Stretch
9cf10eecd1 Release v2.0.3 2017-05-18 14:31:48 -04:00
Jeremy Stretch
f927d5b8f5 Closes #1198: Allow filtering unracked devices on device list 2017-05-18 14:27:07 -04:00
Jeremy Stretch
7fa696dace Fixes #1195: Unable to create an interface connection when searching for peer device 2017-05-18 13:33:26 -04:00
Jeremy Stretch
feac93389c Fixes #1200: Form validation error when connecting power ports to power outlets 2017-05-18 12:11:14 -04:00
Jeremy Stretch
f7969d91b3 Fixes #1199: Bulk import of secrets does not prompt user to generate a session key 2017-05-18 09:17:41 -04:00
Jeremy Stretch
92aafb9043 Added WSGIPassAuthorization to example Apache config 2017-05-17 17:23:08 -04:00
Jeremy Stretch
f9328d53b4 Fixes #1197: Fixed status assignment during bulk import of devices, prefixes, IPs, and VLANs 2017-05-17 17:16:02 -04:00
Jeremy Stretch
f1cbc7da33 Fixes #1157: Hide nav menu search bar on small displays 2017-05-17 16:00:46 -04:00
Jeremy Stretch
01becd21de Closes #1196: Added a lag_id filter to the API interfaces view 2017-05-17 14:43:44 -04:00
Jeremy Stretch
7768b94279 Fixes #1188: Serialize interface LAG as nested objected (API) 2017-05-17 14:32:39 -04:00
Jeremy Stretch
3bc51c8e69 Fixes #1191: Bulk selection of IPs under a prefix incorrect when "select all" is used 2017-05-17 14:23:08 -04:00
Jeremy Stretch
d206be91d5 Fixes #1130: Added zlib1g-dev to Ubuntu/Debian packages list 2017-05-17 12:48:31 -04:00
Jeremy Stretch
6e69c9e375 Restored the option to hide the paginator on panel tables 2017-05-17 12:18:32 -04:00
Jeremy Stretch
f2846af4ec Fixes #1189: Enforce consistent ordering of objects returned by a global search 2017-05-17 12:16:57 -04:00
Jeremy Stretch
657eed1dc9 Merge pull request #1185 from ryanmerolle/patch-1
Added vagrant alternative installation link
2017-05-16 16:53:01 -04:00
Jeremy Stretch
e351ab0171 Fixes #1186: Corrected VLAN edit form so that site assignment is not required 2017-05-16 16:30:28 -04:00
Jeremy Stretch
779446da64 Fixes #1187: Fixed table pagination by introducing a custom table template 2017-05-16 16:19:55 -04:00
ryanmerolle
2ff0d7aa83 Added vagrant alternative installation link 2017-05-16 07:13:05 -04:00
Jeremy Stretch
7ceb64b57b Post-release version bump 2017-05-15 13:24:37 -04:00
bellwood
5ff4e3b194 Enhance LDAP documentation
Incorporating @marvnrawley's enhancements from #518
2017-04-13 17:03:58 -04:00
197 changed files with 3596 additions and 2737 deletions

View File

@@ -45,6 +45,10 @@ sure to include:
* Any error messages generated * Any error messages generated
* Screenshots (if applicable) * 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 * 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 much work is required to resolve them. It may take some time for someone
to address your issue. to address your issue.
@@ -91,6 +95,10 @@ following:
* Any third-party libraries or other resources which would be * Any third-party libraries or other resources which would be
involved 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 ## Submitting Pull Requests
* Be sure to open an issue before starting work on a pull request, and * Be sure to open an issue before starting work on a pull request, and

View File

@@ -33,3 +33,4 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
* [Docker container](https://github.com/digitalocean/netbox-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)) * [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)

View File

@@ -136,3 +136,8 @@ The response will return devices 1 through 100. The URL provided in the `next` a
"results": [...] "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.

View File

@@ -83,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 ## LOGIN_REQUIRED
Default: False Default: False
@@ -99,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_USERNAME
## NETBOX_PASSWORD ## NETBOX_PASSWORD

View File

@@ -1,5 +1,4 @@
This guide explains how to implement LDAP authentication using an external server. User authentication will fall back to 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.
built-in Django users in the event of a failure.
# Requirements # Requirements
@@ -29,6 +28,9 @@ Create a file in the same directory as `configuration.py` (typically `netbox/net
## General Server Configuration ## 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 ```python
import ldap import ldap
@@ -52,6 +54,9 @@ LDAP_IGNORE_CERT_ERRORS = True
## User Authentication ## User Authentication
!!! info
When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
```python ```python
from django_auth_ldap.config import LDAPSearch from django_auth_ldap.config import LDAPSearch
@@ -99,3 +104,16 @@ AUTH_LDAP_FIND_GROUP_PERMS = True
AUTH_LDAP_CACHE_GROUPS = True AUTH_LDAP_CACHE_GROUPS = True
AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 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",
}
```

View File

@@ -1,21 +1,20 @@
# Installation # Installation
**Debian/Ubuntu** **Ubuntu**
Python 3: Python 3:
```no-highlight ```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
# update-alternatives --install /usr/bin/python python /usr/bin/python3 1
``` ```
Python 2: Python 2:
```no-highlight ```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: Python 3:
@@ -57,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: If `git` is not already installed, install it:
**Debian/Ubuntu** **Ubuntu**
```no-highlight ```no-highlight
# apt-get install -y git # apt-get install -y git
``` ```
**CentOS/RHEL** **CentOS**
```no-highlight ```no-highlight
# yum install -y git # yum install -y git
@@ -150,11 +149,14 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
# Run Database Migrations # 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 ```no-highlight
# cd /opt/netbox/netbox/ # cd /opt/netbox/netbox/
# ./manage.py migrate # python manage.py migrate
Operations to perform: Operations to perform:
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
Running migrations: Running migrations:
@@ -172,7 +174,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: 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 ```no-highlight
# ./manage.py createsuperuser # python manage.py createsuperuser
Username: admin Username: admin
Email address: admin@example.com Email address: admin@example.com
Password: Password:
@@ -183,7 +185,7 @@ Superuser created successfully.
# Collect Static Files # Collect Static Files
```no-highlight ```no-highlight
# ./manage.py collectstatic --no-input # python manage.py collectstatic --no-input
You have requested to collect static files at the destination You have requested to collect static files at the destination
location as specified in your settings: location as specified in your settings:
@@ -204,7 +206,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. 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 ```no-highlight
# ./manage.py loaddata initial_data # python manage.py loaddata initial_data
Installed 43 object(s) from 4 fixture(s) Installed 43 object(s) from 4 fixture(s)
``` ```
@@ -213,7 +215,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: At this point, NetBox should be able to run. We can verify this by starting a development instance:
```no-highlight ```no-highlight
# ./manage.py runserver 0.0.0.0:8000 --insecure # python manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks... Performing system checks...
System check identified no issues (0 silenced). System check identified no issues (0 silenced).

View File

@@ -1,15 +1,18 @@
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).) 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 # Installation
**Debian/Ubuntu** **Ubuntu**
```no-highlight ```no-highlight
# apt-get update # apt-get update
# apt-get install -y postgresql libpq-dev # apt-get install -y postgresql libpq-dev
``` ```
**CentOS/RHEL** **CentOS**
```no-highlight ```no-highlight
# yum install -y postgresql postgresql-server postgresql-devel # yum install -y postgresql postgresql-server postgresql-devel

View File

@@ -52,12 +52,27 @@ Once the new code is in place, run the upgrade script (which may need to be run
# ./upgrade.sh # ./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: This script:
* Installs or upgrades any new required Python packages * Installs or upgrades any new required Python packages
* Applies any database migrations that were included in the release * Applies any database migrations that were included in the release
* Collects all static files to be served by the HTTP service * 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 # 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`: Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:

View File

@@ -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. 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 !!! 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 ```no-highlight
# apt-get install -y gunicorn supervisor # apt-get install -y gunicorn supervisor
@@ -73,6 +73,9 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
Alias /static /opt/netbox/netbox/static Alias /static /opt/netbox/netbox/static
# Needed to allow token-based API authentication
WSGIPassAuthorization on
<Directory /opt/netbox/netbox/static> <Directory /opt/netbox/netbox/static>
Options Indexes FollowSymLinks MultiViews Options Indexes FollowSymLinks MultiViews
AllowOverride None AllowOverride None

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from circuits.models import Provider, Circuit, CircuitTermination, CircuitType

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers from rest_framework import routers
from . import views from . import views

View File

@@ -1,9 +1,11 @@
from django.shortcuts import get_object_or_404 from __future__ import unicode_literals
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from django.shortcuts import get_object_or_404
from circuits import filters from circuits import filters
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
from extras.models import Graph, GRAPH_TYPE_PROVIDER from extras.models import Graph, GRAPH_TYPE_PROVIDER

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.apps import AppConfig from django.apps import AppConfig

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
import django_filters import django_filters
from django.db.models import Q from django.db.models import Q

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
@@ -6,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField,
FilterChoiceField, Livesearch, SmallTextarea, SlugField, SmallTextarea, SlugField,
) )
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -37,15 +39,18 @@ class ProviderForm(BootstrapMixin, CustomFieldForm):
} }
class ProviderFromCSVForm(forms.ModelForm): class ProviderCSVForm(forms.ModelForm):
slug = SlugField()
class Meta: class Meta:
model = Provider model = Provider
fields = ['name', 'slug', 'asn', 'account', 'portal_url'] fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'comments']
help_texts = {
'name': 'Provider name',
class ProviderImportForm(BootstrapMixin, BulkImportForm): 'asn': '32-bit autonomous system number',
csv = CSVDataField(csv_form=ProviderFromCSVForm) 'portal_url': 'Portal URL',
'comments': 'Free-form comments',
}
class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -100,21 +105,36 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class CircuitFromCSVForm(forms.ModelForm): class CircuitCSVForm(forms.ModelForm):
provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', provider = forms.ModelChoiceField(
error_messages={'invalid_choice': 'Provider not found.'}) queryset=Provider.objects.all(),
type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', to_field_name='name',
error_messages={'invalid_choice': 'Invalid circuit type.'}) help_text='Name of parent provider',
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, error_messages={
error_messages={'invalid_choice': 'Tenant not found.'}) '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: class Meta:
model = Circuit model = Circuit
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description'] fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
class CircuitImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=CircuitFromCSVForm)
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -165,7 +185,9 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
) )
rack = ChainedModelChoiceField( rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'site'}, chains=(
('site', 'site'),
),
required=False, required=False,
label='Rack', label='Rack',
widget=APISelect( widget=APISelect(
@@ -175,7 +197,10 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
) )
device = ChainedModelChoiceField( device = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'}, chains=(
('site', 'site'),
('rack', 'rack'),
),
required=False, required=False,
label='Device', label='Device',
widget=APISelect( widget=APISelect(
@@ -184,32 +209,27 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
attrs={'filter-for': 'interface'} 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 = ChainedModelChoiceField( interface = ChainedModelChoiceField(
queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related( queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b' 'circuit_termination', 'connected_as_a', 'connected_as_b'
), ),
chains={'device': 'device'}, chains=(
('device', 'device'),
),
required=False, required=False,
label='Interface', label='Interface',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical', api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
disabled_indicator='is_connected' disabled_indicator='connection'
) )
) )
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed', fields = [
'xconnect_id', 'pp_info'] 'term_side', 'site', 'rack', 'device', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info',
]
help_texts = { help_texts = {
'port_speed': "Physical circuit speed", 'port_speed': "Physical circuit speed",
'xconnect_id': "ID of the local cross-connect", 'xconnect_id': "ID of the local cross-connect",

View File

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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@@ -50,6 +52,8 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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: class Meta:
ordering = ['name'] ordering = ['name']
@@ -105,12 +109,14 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
comments = models.TextField(blank=True) comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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: class Meta:
ordering = ['provider', 'cid'] ordering = ['provider', 'cid']
unique_together = ['provider', 'cid'] unique_together = ['provider', 'cid']
def __str__(self): def __str__(self):
return u'{} {}'.format(self.provider, self.cid) return '{} {}'.format(self.provider, self.cid)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk]) return reverse('circuits:circuit', args=[self.pk])
@@ -166,7 +172,7 @@ class CircuitTermination(models.Model):
unique_together = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side']
def __str__(self): 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): def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A' peer_side = 'Z' if self.term_side == 'A' else 'A'

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone

View File

@@ -1,8 +1,9 @@
from __future__ import unicode_literals
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from utilities.tables import BaseTable, SearchTable, ToggleColumn from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import Circuit, CircuitType, Provider from .models import Circuit, CircuitType, Provider

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from . import views from . import views
@@ -8,33 +10,33 @@ urlpatterns = [
# Providers # Providers
url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'), 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/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), 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/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
url(r'^providers/(?P<slug>[\w-]+)/$', views.provider, name='provider'), url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'), url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'), url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
# Circuit types # Circuit types
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), 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/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
# Circuits # Circuits
url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'), 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/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), 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/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'), url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations # Circuit terminations
url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -5,13 +7,13 @@ from django.db import transaction
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.generic import View
from extras.models import Graph, GRAPH_TYPE_PROVIDER from extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from . import filters, forms, tables from . import filters, forms, tables
from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
@@ -28,11 +30,16 @@ class ProviderListView(ObjectListView):
template_name = 'circuits/provider_list.html' template_name = 'circuits/provider_list.html'
def provider(request, slug): class ProviderView(View):
def get(self, request, slug):
provider = get_object_or_404(Provider, slug=slug) provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\ circuits = Circuit.objects.filter(provider=provider).select_related(
.prefetch_related('terminations__site') 'type', 'tenant'
).prefetch_related(
'terminations__site'
)
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
return render(request, 'circuits/provider.html', { return render(request, 'circuits/provider.html', {
@@ -42,14 +49,18 @@ def provider(request, slug):
}) })
class ProviderEditView(PermissionRequiredMixin, ObjectEditView): class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_provider' permission_required = 'circuits.add_provider'
model = Provider model = Provider
form_class = forms.ProviderForm form_class = forms.ProviderForm
template_name = 'circuits/provider_edit.html' template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
class ProviderEditView(ProviderCreateView):
permission_required = 'circuits.change_provider'
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider' permission_required = 'circuits.delete_provider'
model = Provider model = Provider
@@ -58,9 +69,8 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_provider' permission_required = 'circuits.add_provider'
form = forms.ProviderImportForm model_form = forms.ProviderCSVForm
table = tables.ProviderTable table = tables.ProviderTable
template_name = 'circuits/provider_import.html'
default_return_url = 'circuits:provider_list' default_return_url = 'circuits:provider_list'
@@ -90,8 +100,8 @@ class CircuitTypeListView(ObjectListView):
template_name = 'circuits/circuittype_list.html' template_name = 'circuits/circuittype_list.html'
class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView): class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittype' permission_required = 'circuits.add_circuittype'
model = CircuitType model = CircuitType
form_class = forms.CircuitTypeForm form_class = forms.CircuitTypeForm
@@ -99,6 +109,10 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('circuits:circuittype_list') return reverse('circuits:circuittype_list')
class CircuitTypeEditView(CircuitTypeCreateView):
permission_required = 'circuits.change_circuittype'
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype' permission_required = 'circuits.delete_circuittype'
cls = CircuitType cls = CircuitType
@@ -117,7 +131,9 @@ class CircuitListView(ObjectListView):
template_name = 'circuits/circuit_list.html' template_name = 'circuits/circuit_list.html'
def circuit(request, pk): class CircuitView(View):
def get(self, request, pk):
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk) circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.select_related( termination_a = CircuitTermination.objects.select_related(
@@ -138,14 +154,18 @@ def circuit(request, pk):
}) })
class CircuitEditView(PermissionRequiredMixin, ObjectEditView): class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuit' permission_required = 'circuits.add_circuit'
model = Circuit model = Circuit
form_class = forms.CircuitForm form_class = forms.CircuitForm
template_name = 'circuits/circuit_edit.html' template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
class CircuitEditView(CircuitCreateView):
permission_required = 'circuits.change_circuit'
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView): class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit' permission_required = 'circuits.delete_circuit'
model = Circuit model = Circuit
@@ -154,9 +174,8 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuit' permission_required = 'circuits.add_circuit'
form = forms.CircuitImportForm model_form = forms.CircuitCSVForm
table = tables.CircuitTable table = tables.CircuitTable
template_name = 'circuits/circuit_import.html'
default_return_url = 'circuits:circuit_list' default_return_url = 'circuits:circuit_list'
@@ -225,8 +244,8 @@ def circuit_terminations_swap(request, pk):
# Circuit terminations # Circuit terminations
# #
class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView): class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittermination' permission_required = 'circuits.add_circuittermination'
model = CircuitTermination model = CircuitTermination
form_class = forms.CircuitTerminationForm form_class = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html' template_name = 'circuits/circuittermination_edit.html'
@@ -240,6 +259,10 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
return obj.circuit.get_absolute_url() return obj.circuit.get_absolute_url()
class CircuitTerminationEditView(CircuitTerminationCreateView):
permission_required = 'circuits.change_circuittermination'
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination' permission_required = 'circuits.delete_circuittermination'
model = CircuitTermination model = CircuitTermination

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
@@ -581,9 +583,18 @@ class WritablePowerPortSerializer(serializers.ModelSerializer):
# Interfaces # Interfaces
# #
class NestedInterfaceSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
class Meta:
model = Interface
fields = ['id', 'url', 'name']
class InterfaceSerializer(serializers.ModelSerializer): class InterfaceSerializer(serializers.ModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
lag = NestedInterfaceSerializer()
connection = serializers.SerializerMethodField(read_only=True) connection = serializers.SerializerMethodField(read_only=True)
connected_interface = serializers.SerializerMethodField(read_only=True) connected_interface = serializers.SerializerMethodField(read_only=True)
@@ -609,10 +620,11 @@ class PeerInterfaceSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
lag = NestedInterfaceSerializer()
class Meta: class Meta:
model = Interface model = Interface
fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description'] fields = ['id', 'url', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
class WritableInterfaceSerializer(serializers.ModelSerializer): class WritableInterfaceSerializer(serializers.ModelSerializer):

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers from rest_framework import routers
from . import views from . import views

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.mixins import ListModelMixin from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.apps import AppConfig from django.apps import AppConfig

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from netaddr import EUI, mac_unix_expanded from netaddr import EUI, mac_unix_expanded
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
import django_filters import django_filters
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
@@ -477,6 +479,11 @@ class InterfaceFilter(DeviceComponentFilterSet):
method='filter_type', method='filter_type',
label='Interface type', label='Interface type',
) )
lag_id = django_filters.ModelMultipleChoiceFilter(
name='lag',
queryset=Interface.objects.all(),
label='LAG interface (ID)',
)
mac_address = django_filters.CharFilter( mac_address = django_filters.CharFilter(
method='_mac_address', method='_mac_address',
label='MAC address', label='MAC address',

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from netaddr import EUI, AddrFormatError from netaddr import EUI, AddrFormatError
from django import forms from django import forms

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,3 +1,4 @@
from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from itertools import count, groupby from itertools import count, groupby
@@ -23,7 +24,6 @@ from utilities.fields import ColorField, NullableCharField
from utilities.managers import NaturalOrderByManager from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format from utilities.utils import csv_format
from .fields import ASNField, MACAddressField from .fields import ASNField, MACAddressField
@@ -280,6 +280,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
objects = SiteManager() objects = SiteManager()
csv_headers = [
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email',
]
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
@@ -346,7 +350,7 @@ class RackGroup(models.Model):
] ]
def __str__(self): def __str__(self):
return u'{} - {}'.format(self.site.name, self.name) return self.name
def get_absolute_url(self): def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
@@ -402,6 +406,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
objects = RackManager() objects = RackManager()
csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
]
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']
unique_together = [ unique_together = [
@@ -466,10 +474,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
@property @property
def display_name(self): def display_name(self):
if self.facility_id: if self.facility_id:
return u"{} ({})".format(self.name, self.facility_id) return "{} ({})".format(self.name, self.facility_id)
elif self.name: elif self.name:
return self.name return self.name
return u"" return ""
def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False): def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
""" """
@@ -569,7 +577,7 @@ class RackReservation(models.Model):
ordering = ['created'] ordering = ['created']
def __str__(self): def __str__(self):
return u"Reservation for rack {}".format(self.rack) return "Reservation for rack {}".format(self.rack)
def clean(self): def clean(self):
@@ -579,7 +587,7 @@ class RackReservation(models.Model):
invalid_units = [u for u in self.units if u not in self.rack.units] invalid_units = [u for u in self.units if u not in self.rack.units]
if invalid_units: if invalid_units:
raise ValidationError({ raise ValidationError({
'units': u"Invalid unit(s) for {}U rack: {}".format( 'units': "Invalid unit(s) for {}U rack: {}".format(
self.rack.u_height, self.rack.u_height,
', '.join([str(u) for u in invalid_units]), ', '.join([str(u) for u in invalid_units]),
), ),
@@ -733,7 +741,7 @@ class DeviceType(models.Model, CustomFieldModel):
@property @property
def full_name(self): def full_name(self):
return u'{} {}'.format(self.manufacturer.name, self.model) return '{} {}'.format(self.manufacturer.name, self.model)
@property @property
def is_parent_device(self): def is_parent_device(self):
@@ -981,6 +989,11 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
objects = DeviceManager() 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: class Meta:
ordering = ['name'] ordering = ['name']
unique_together = ['rack', 'position', 'face'] unique_together = ['rack', 'position', 'face']
@@ -1096,6 +1109,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
self.asset_tag, self.asset_tag,
self.get_status_display(), self.get_status_display(),
self.site.name, 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.rack.name if self.rack else None,
self.position, self.position,
self.get_face_display(), self.get_face_display(),
@@ -1106,8 +1120,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
if self.name: if self.name:
return self.name return self.name
elif hasattr(self, 'device_type'): elif hasattr(self, 'device_type'):
return u"{}".format(self.device_type) return "{}".format(self.device_type)
return u"" return ""
@property @property
def identifier(self): def identifier(self):
@@ -1162,6 +1176,8 @@ class ConsolePort(models.Model):
verbose_name='Console server port', blank=True, null=True) verbose_name='Console server port', blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) 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: class Meta:
ordering = ['device', 'name'] ordering = ['device', 'name']
unique_together = ['device', 'name'] unique_together = ['device', 'name']
@@ -1231,6 +1247,8 @@ class PowerPort(models.Model):
blank=True, null=True) blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
csv_headers = ['pdu', 'power_outlet', 'device', 'power_port', 'connection_status']
class Meta: class Meta:
ordering = ['device', 'name'] ordering = ['device', 'name']
unique_together = ['device', 'name'] unique_together = ['device', 'name']
@@ -1320,7 +1338,7 @@ class Interface(models.Model):
# An interface's LAG must belong to the same device # An interface's LAG must belong to the same device
if self.lag and self.lag.device != self.device: if self.lag and self.lag.device != self.device:
raise ValidationError({ 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 self.lag.name, self.lag.device.name
) )
}) })
@@ -1328,14 +1346,14 @@ class Interface(models.Model):
# A virtual interface cannot have a parent LAG # 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 VIRTUAL_IFACE_TYPES and self.lag is not None:
raise ValidationError({ 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 # Only a LAG can have LAG members
if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists(): if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
raise ValidationError({ raise ValidationError({
'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format( '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()])
) )
}) })
@@ -1392,11 +1410,16 @@ class InterfaceConnection(models.Model):
connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED,
verbose_name='Status') verbose_name='Status')
csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
def clean(self): def clean(self):
try:
if self.interface_a == self.interface_b: if self.interface_a == self.interface_b:
raise ValidationError({ raise ValidationError({
'interface_b': "Cannot connect an interface to itself." 'interface_b': "Cannot connect an interface to itself."
}) })
except ObjectDoesNotExist:
pass
# Used for connections export # Used for connections export
def to_csv(self): def to_csv(self):
@@ -1428,7 +1451,7 @@ class DeviceBay(models.Model):
unique_together = ['device', 'name'] unique_together = ['device', 'name']
def __str__(self): def __str__(self):
return u'{} - {}'.format(self.device.name, self.name) return '{} - {}'.format(self.device.name, self.name)
def clean(self): def clean(self):

View File

@@ -1,8 +1,9 @@
from __future__ import unicode_literals
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from utilities.tables import BaseTable, SearchTable, ToggleColumn from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
@@ -246,7 +247,7 @@ class RackImportTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Rack model = Rack
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height') fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'u_height')
# #

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase

View File

@@ -1,4 +1,7 @@
from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase
from dcim.forms import * from dcim.forms import *
from dcim.models import * from dcim.models import *

View File

@@ -1,4 +1,7 @@
from __future__ import unicode_literals
from django.test import TestCase from django.test import TestCase
from dcim.models import * from dcim.models import *

View File

@@ -1,9 +1,10 @@
from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from ipam.views import ServiceEditView
from secrets.views import secret_add
from extras.views import ImageAttachmentEditView from extras.views import ImageAttachmentEditView
from ipam.views import ServiceCreateView
from secrets.views import secret_add
from .models import Device, Rack, Site from .models import Device, Rack, Site
from . import views from . import views
@@ -13,29 +14,29 @@ urlpatterns = [
# Regions # Regions
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), 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/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
# Sites # Sites
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), 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/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'), url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups # Rack groups
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'), 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/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
# Rack roles # Rack roles
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), 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/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
@@ -52,83 +53,83 @@ urlpatterns = [
url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'), 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/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
url(r'^racks/(?P<pk>\d+)/$', views.rack, name='rack'), url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'), url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers # Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), 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/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
# Device types # Device types
url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), 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/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/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
url(r'^device-types/(?P<pk>\d+)/$', views.devicetype, name='devicetype'), url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
# Console port templates # Console port templates
url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateAddView.as_view(), name='devicetype_add_consoleport'), url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
url(r'^device-types/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'), url(r'^device-types/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
# Console server port templates # Console server port templates
url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateAddView.as_view(), name='devicetype_add_consoleserverport'), url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
url(r'^device-types/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'), url(r'^device-types/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
# Power port templates # Power port templates
url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateAddView.as_view(), name='devicetype_add_powerport'), url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
url(r'^device-types/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'), url(r'^device-types/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
# Power outlet templates # Power outlet templates
url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateAddView.as_view(), name='devicetype_add_poweroutlet'), url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
url(r'^device-types/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'), url(r'^device-types/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
# Interface templates # Interface templates
url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateAddView.as_view(), name='devicetype_add_interface'), url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
# Device bay templates # Device bay templates
url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateAddView.as_view(), name='devicetype_add_devicebay'), url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
# Device roles # Device roles
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'), 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/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
# Platforms # Platforms
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), 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/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
# Devices # Devices
url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), 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/$', views.DeviceBulkImportView.as_view(), name='device_import'),
url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), 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/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'), url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'), url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'), url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'), url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'), url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports # Console ports
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortAddView.as_view(), name='consoleport_add'), url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'), url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'), url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
@@ -137,7 +138,8 @@ urlpatterns = [
# Console server ports # Console server ports
url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortAddView.as_view(), name='consoleserverport_add'), url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'), url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'), url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
@@ -146,7 +148,7 @@ urlpatterns = [
# Power ports # Power ports
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortAddView.as_view(), name='powerport_add'), url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'), url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'), url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
@@ -155,7 +157,8 @@ urlpatterns = [
# Power outlets # Power outlets
url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletAddView.as_view(), name='poweroutlet_add'), url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'), url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'), url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
@@ -164,8 +167,9 @@ urlpatterns = [
# Interfaces # Interfaces
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceAddView.as_view(), name='interface_add'), url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
@@ -174,7 +178,7 @@ urlpatterns = [
# Device bays # Device bays
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayAddView.as_view(), name='devicebay_add'), url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),

View File

@@ -1,3 +1,4 @@
from __future__ import unicode_literals
from copy import deepcopy from copy import deepcopy
import re import re
from natsort import natsorted from natsort import natsorted
@@ -7,7 +8,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db.models import Count from django.db.models import Count, Q
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@@ -24,13 +25,12 @@ from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from . import filters, forms, tables from . import filters, forms, tables
from .models import ( from .models import (
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackReservation, RackRole, Region, Site, RackGroup, RackReservation, RackRole, Region, Site,
) )
@@ -109,11 +109,11 @@ class ComponentCreateView(View):
if field == 'name': if field == 'name':
field = 'name_pattern' field = 'name_pattern'
for e in errors: 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: if not form.errors:
self.model.objects.bulk_create(new_components) 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 len(new_components), self.model._meta.verbose_name_plural, parent
)) ))
if '_addanother' in request.POST: if '_addanother' in request.POST:
@@ -141,6 +141,44 @@ class ComponentDeleteView(ObjectDeleteView):
return obj.device.get_absolute_url() 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 # Regions
# #
@@ -151,8 +189,8 @@ class RegionListView(ObjectListView):
template_name = 'dcim/region_list.html' template_name = 'dcim/region_list.html'
class RegionEditView(PermissionRequiredMixin, ObjectEditView): class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_region' permission_required = 'dcim.add_region'
model = Region model = Region
form_class = forms.RegionForm form_class = forms.RegionForm
@@ -160,6 +198,10 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:region_list') return reverse('dcim:region_list')
class RegionEditView(RegionCreateView):
permission_required = 'dcim.change_region'
class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region' permission_required = 'dcim.delete_region'
cls = Region cls = Region
@@ -178,7 +220,9 @@ class SiteListView(ObjectListView):
template_name = 'dcim/site_list.html' template_name = 'dcim/site_list.html'
def site(request, slug): class SiteView(View):
def get(self, request, slug):
site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug) site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug)
stats = { stats = {
@@ -201,14 +245,18 @@ def site(request, slug):
}) })
class SiteEditView(PermissionRequiredMixin, ObjectEditView): class SiteCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_site' permission_required = 'dcim.add_site'
model = Site model = Site
form_class = forms.SiteForm form_class = forms.SiteForm
template_name = 'dcim/site_edit.html' template_name = 'dcim/site_edit.html'
default_return_url = 'dcim:site_list' default_return_url = 'dcim:site_list'
class SiteEditView(SiteCreateView):
permission_required = 'dcim.change_site'
class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site' permission_required = 'dcim.delete_site'
model = Site model = Site
@@ -217,9 +265,8 @@ class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_site' permission_required = 'dcim.add_site'
form = forms.SiteImportForm model_form = forms.SiteCSVForm
table = tables.SiteTable table = tables.SiteTable
template_name = 'dcim/site_import.html'
default_return_url = 'dcim:site_list' default_return_url = 'dcim:site_list'
@@ -244,8 +291,8 @@ class RackGroupListView(ObjectListView):
template_name = 'dcim/rackgroup_list.html' template_name = 'dcim/rackgroup_list.html'
class RackGroupEditView(PermissionRequiredMixin, ObjectEditView): class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackgroup' permission_required = 'dcim.add_rackgroup'
model = RackGroup model = RackGroup
form_class = forms.RackGroupForm form_class = forms.RackGroupForm
@@ -253,6 +300,10 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:rackgroup_list') return reverse('dcim:rackgroup_list')
class RackGroupEditView(RackGroupCreateView):
permission_required = 'dcim.change_rackgroup'
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackgroup' permission_required = 'dcim.delete_rackgroup'
cls = RackGroup cls = RackGroup
@@ -270,8 +321,8 @@ class RackRoleListView(ObjectListView):
template_name = 'dcim/rackrole_list.html' template_name = 'dcim/rackrole_list.html'
class RackRoleEditView(PermissionRequiredMixin, ObjectEditView): class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackrole' permission_required = 'dcim.add_rackrole'
model = RackRole model = RackRole
form_class = forms.RackRoleForm form_class = forms.RackRoleForm
@@ -279,6 +330,10 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:rackrole_list') return reverse('dcim:rackrole_list')
class RackRoleEditView(RackRoleCreateView):
permission_required = 'dcim.change_rackrole'
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackrole' permission_required = 'dcim.delete_rackrole'
cls = RackRole cls = RackRole
@@ -290,8 +345,13 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# #
class RackListView(ObjectListView): class RackListView(ObjectListView):
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\ queryset = Rack.objects.select_related(
.annotate(device_count=Count('devices', distinct=True)) 'site', 'group', 'tenant', 'role'
).prefetch_related(
'devices__device_type'
).annotate(
device_count=Count('devices', distinct=True)
)
filter = filters.RackFilter filter = filters.RackFilter
filter_form = forms.RackFilterForm filter_form = forms.RackFilterForm
table = tables.RackTable table = tables.RackTable
@@ -338,7 +398,9 @@ class RackElevationListView(View):
}) })
def rack(request, pk): 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) rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
@@ -365,14 +427,18 @@ def rack(request, pk):
}) })
class RackEditView(PermissionRequiredMixin, ObjectEditView): class RackCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rack' permission_required = 'dcim.add_rack'
model = Rack model = Rack
form_class = forms.RackForm form_class = forms.RackForm
template_name = 'dcim/rack_edit.html' template_name = 'dcim/rack_edit.html'
default_return_url = 'dcim:rack_list' default_return_url = 'dcim:rack_list'
class RackEditView(RackCreateView):
permission_required = 'dcim.change_rack'
class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rack' permission_required = 'dcim.delete_rack'
model = Rack model = Rack
@@ -381,9 +447,8 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class RackBulkImportView(PermissionRequiredMixin, BulkImportView): class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rack' permission_required = 'dcim.add_rack'
form = forms.RackImportForm model_form = forms.RackCSVForm
table = tables.RackImportTable table = tables.RackImportTable
template_name = 'dcim/rack_import.html'
default_return_url = 'dcim:rack_list' default_return_url = 'dcim:rack_list'
@@ -415,8 +480,8 @@ class RackReservationListView(ObjectListView):
template_name = 'dcim/rackreservation_list.html' template_name = 'dcim/rackreservation_list.html'
class RackReservationEditView(PermissionRequiredMixin, ObjectEditView): class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackreservation' permission_required = 'dcim.add_rackreservation'
model = RackReservation model = RackReservation
form_class = forms.RackReservationForm form_class = forms.RackReservationForm
@@ -430,6 +495,10 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
return obj.rack.get_absolute_url() return obj.rack.get_absolute_url()
class RackReservationEditView(RackReservationCreateView):
permission_required = 'dcim.change_rackreservation'
class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rackreservation' permission_required = 'dcim.delete_rackreservation'
model = RackReservation model = RackReservation
@@ -454,8 +523,8 @@ class ManufacturerListView(ObjectListView):
template_name = 'dcim/manufacturer_list.html' template_name = 'dcim/manufacturer_list.html'
class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView): class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_manufacturer' permission_required = 'dcim.add_manufacturer'
model = Manufacturer model = Manufacturer
form_class = forms.ManufacturerForm form_class = forms.ManufacturerForm
@@ -463,6 +532,10 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:manufacturer_list') return reverse('dcim:manufacturer_list')
class ManufacturerEditView(ManufacturerCreateView):
permission_required = 'dcim.change_manufacturer'
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_manufacturer' permission_required = 'dcim.delete_manufacturer'
cls = Manufacturer cls = Manufacturer
@@ -481,7 +554,9 @@ class DeviceTypeListView(ObjectListView):
template_name = 'dcim/devicetype_list.html' template_name = 'dcim/devicetype_list.html'
def devicetype(request, pk): class DeviceTypeView(View):
def get(self, request, pk):
devicetype = get_object_or_404(DeviceType, pk=pk) devicetype = get_object_or_404(DeviceType, pk=pk)
@@ -499,12 +574,14 @@ def devicetype(request, pk):
natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
) )
mgmt_interface_table = tables.InterfaceTemplateTable( mgmt_interface_table = tables.InterfaceTemplateTable(
list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype, list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(
mgmt_only=True)) device_type=devicetype, mgmt_only=True
))
) )
interface_table = tables.InterfaceTemplateTable( interface_table = tables.InterfaceTemplateTable(
list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(device_type=devicetype, list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter(
mgmt_only=False)) device_type=devicetype, mgmt_only=False
))
) )
devicebay_table = tables.DeviceBayTemplateTable( devicebay_table = tables.DeviceBayTemplateTable(
natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name'))
@@ -530,14 +607,18 @@ def devicetype(request, pk):
}) })
class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView): class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicetype' permission_required = 'dcim.add_devicetype'
model = DeviceType model = DeviceType
form_class = forms.DeviceTypeForm form_class = forms.DeviceTypeForm
template_name = 'dcim/devicetype_edit.html' template_name = 'dcim/devicetype_edit.html'
default_return_url = 'dcim:devicetype_list' default_return_url = 'dcim:devicetype_list'
class DeviceTypeEditView(DeviceTypeCreateView):
permission_required = 'dcim.change_devicetype'
class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicetype' permission_required = 'dcim.delete_devicetype'
model = DeviceType model = DeviceType
@@ -564,7 +645,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device type components # Device type components
# #
class ConsolePortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleporttemplate' permission_required = 'dcim.add_consoleporttemplate'
parent_model = DeviceType parent_model = DeviceType
parent_field = 'device_type' parent_field = 'device_type'
@@ -581,7 +662,7 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
parent_cls = DeviceType parent_cls = DeviceType
class ConsoleServerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverporttemplate' permission_required = 'dcim.add_consoleserverporttemplate'
parent_model = DeviceType parent_model = DeviceType
parent_field = 'device_type' parent_field = 'device_type'
@@ -596,7 +677,7 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet
parent_cls = DeviceType parent_cls = DeviceType
class PowerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerporttemplate' permission_required = 'dcim.add_powerporttemplate'
parent_model = DeviceType parent_model = DeviceType
parent_field = 'device_type' parent_field = 'device_type'
@@ -611,7 +692,7 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
parent_cls = DeviceType parent_cls = DeviceType
class PowerOutletTemplateAddView(PermissionRequiredMixin, ComponentCreateView): class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlettemplate' permission_required = 'dcim.add_poweroutlettemplate'
parent_model = DeviceType parent_model = DeviceType
parent_field = 'device_type' parent_field = 'device_type'
@@ -626,7 +707,7 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView)
parent_cls = DeviceType parent_cls = DeviceType
class InterfaceTemplateAddView(PermissionRequiredMixin, ComponentCreateView): class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interfacetemplate' permission_required = 'dcim.add_interfacetemplate'
parent_model = DeviceType parent_model = DeviceType
parent_field = 'device_type' parent_field = 'device_type'
@@ -649,7 +730,7 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
parent_cls = DeviceType parent_cls = DeviceType
class DeviceBayTemplateAddView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebaytemplate' permission_required = 'dcim.add_devicebaytemplate'
parent_model = DeviceType parent_model = DeviceType
parent_field = 'device_type' parent_field = 'device_type'
@@ -674,8 +755,8 @@ class DeviceRoleListView(ObjectListView):
template_name = 'dcim/devicerole_list.html' template_name = 'dcim/devicerole_list.html'
class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView): class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_devicerole' permission_required = 'dcim.add_devicerole'
model = DeviceRole model = DeviceRole
form_class = forms.DeviceRoleForm form_class = forms.DeviceRoleForm
@@ -683,6 +764,10 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:devicerole_list') return reverse('dcim:devicerole_list')
class DeviceRoleEditView(DeviceRoleCreateView):
permission_required = 'dcim.change_devicerole'
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicerole' permission_required = 'dcim.delete_devicerole'
cls = DeviceRole cls = DeviceRole
@@ -699,8 +784,8 @@ class PlatformListView(ObjectListView):
template_name = 'dcim/platform_list.html' template_name = 'dcim/platform_list.html'
class PlatformEditView(PermissionRequiredMixin, ObjectEditView): class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_platform' permission_required = 'dcim.add_platform'
model = Platform model = Platform
form_class = forms.PlatformForm form_class = forms.PlatformForm
@@ -708,6 +793,10 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('dcim:platform_list') return reverse('dcim:platform_list')
class PlatformEditView(PlatformCreateView):
permission_required = 'dcim.change_platform'
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_platform' permission_required = 'dcim.delete_platform'
cls = Platform cls = Platform
@@ -727,7 +816,9 @@ class DeviceListView(ObjectListView):
template_name = 'dcim/device_list.html' template_name = 'dcim/device_list.html'
def device(request, pk): class DeviceView(View):
def get(self, request, pk):
device = get_object_or_404(Device.objects.select_related( device = get_object_or_404(Device.objects.select_related(
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
@@ -744,14 +835,18 @@ def device(request, pk):
power_outlets = natsorted( power_outlets = natsorted(
PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name') PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
) )
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\ interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(
.filter(device=device, mgmt_only=False)\ device=device, mgmt_only=False
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', ).select_related(
'circuit_termination__circuit').prefetch_related('ip_addresses') 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\ 'circuit_termination__circuit'
.filter(device=device, mgmt_only=True)\ ).prefetch_related('ip_addresses')
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter(
'circuit_termination__circuit').prefetch_related('ip_addresses') device=device, mgmt_only=True
).select_related(
'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit'
).prefetch_related('ip_addresses')
device_bays = natsorted( device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name') key=attrgetter('name')
@@ -759,20 +854,14 @@ def device(request, pk):
services = Service.objects.filter(device=device) services = Service.objects.filter(device=device)
secrets = device.secrets.all() secrets = device.secrets.all()
# Find any related devices for convenient linking in the UI # Find up to ten devices in the same site with the same functional role for quick reference.
related_devices = [] related_devices = Device.objects.filter(
if device.name: site=device.site, device_role=device.device_role
if re.match('.+[0-9]+$', device.name): ).exclude(
# Strip 1 or more trailing digits (e.g. core-switch1) pk=device.pk
base_name = re.match('(.*?)[0-9]+$', device.name).group(1) ).select_related(
elif re.match('.+\d[a-z]$', device.name.lower()): 'rack', 'device_type__manufacturer'
# Strip a trailing letter if preceded by a digit (e.g. dist-switch3a -> dist-switch3) )[:10]
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]
# Show graph button on interfaces only if at least one graph has been created. # 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_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
@@ -793,14 +882,56 @@ def device(request, pk):
}) })
class DeviceEditView(PermissionRequiredMixin, ObjectEditView): class DeviceInventoryView(View):
permission_required = 'dcim.change_device'
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 DeviceLLDPNeighborsView(View):
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 DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_device'
model = Device model = Device
form_class = forms.DeviceForm form_class = forms.DeviceForm
template_name = 'dcim/device_edit.html' template_name = 'dcim/device_edit.html'
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
class DeviceEditView(DeviceCreateView):
permission_required = 'dcim.change_device'
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device' permission_required = 'dcim.delete_device'
model = Device model = Device
@@ -809,7 +940,7 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_device' permission_required = 'dcim.add_device'
form = forms.DeviceImportForm model_form = forms.DeviceCSVForm
table = tables.DeviceImportTable table = tables.DeviceImportTable
template_name = 'dcim/device_import.html' template_name = 'dcim/device_import.html'
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
@@ -817,23 +948,22 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_device' permission_required = 'dcim.add_device'
form = forms.ChildDeviceImportForm model_form = forms.ChildDeviceCSVForm
table = tables.DeviceImportTable table = tables.DeviceImportTable
template_name = 'dcim/device_import_child.html' template_name = 'dcim/device_import_child.html'
default_return_url = 'dcim:device_list' 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 = obj_form.save()
obj.site = obj.parent_bay.device.site
obj.rack = obj.parent_bay.device.rack
obj.save()
# Save the reverse relation # Save the reverse relation to the parent device bay
device_bay = obj.parent_bay device_bay = obj.parent_bay
device_bay.installed_device = obj device_bay.installed_device = obj
device_bay.save() device_bay.save()
return obj
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_device' permission_required = 'dcim.change_device'
@@ -851,35 +981,11 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
default_return_url = 'dcim:device_list' default_return_url = 'dcim:device_list'
def device_inventory(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,
})
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 # Console ports
# #
class ConsolePortAddView(PermissionRequiredMixin, ComponentCreateView): class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleport' permission_required = 'dcim.add_consoleport'
parent_model = Device parent_model = Device
parent_field = 'device' parent_field = 'device'
@@ -897,7 +1003,7 @@ def consoleport_connect(request, pk):
form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport) form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport)
if form.is_valid(): if form.is_valid():
consoleport = form.save() consoleport = form.save()
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format( msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(), consoleport.device.get_absolute_url(),
escape(consoleport.device), escape(consoleport.device),
escape(consoleport.name), escape(consoleport.name),
@@ -911,9 +1017,9 @@ def consoleport_connect(request, pk):
else: else:
form = forms.ConsolePortConnectionForm(instance=consoleport, initial={ form = forms.ConsolePortConnectionForm(instance=consoleport, initial={
'site': request.GET.get('site', consoleport.device.site), 'site': request.GET.get('site'),
'rack': request.GET.get('rack', None), 'rack': request.GET.get('rack'),
'console_server': request.GET.get('console_server', None), 'console_server': request.GET.get('console_server'),
'connection_status': CONNECTION_STATUS_CONNECTED, 'connection_status': CONNECTION_STATUS_CONNECTED,
}) })
@@ -931,7 +1037,7 @@ def consoleport_disconnect(request, pk):
if not consoleport.cs_port: if not consoleport.cs_port:
messages.warning( messages.warning(
request, u"Cannot disconnect console port {}: It is not connected to anything.".format(consoleport) request, "Cannot disconnect console port {}: It is not connected to anything.".format(consoleport)
) )
return redirect('dcim:device', pk=consoleport.device.pk) return redirect('dcim:device', pk=consoleport.device.pk)
@@ -942,7 +1048,7 @@ def consoleport_disconnect(request, pk):
consoleport.cs_port = None consoleport.cs_port = None
consoleport.connection_status = None consoleport.connection_status = None
consoleport.save() consoleport.save()
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format( msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(), consoleport.device.get_absolute_url(),
escape(consoleport.device), escape(consoleport.device),
escape(consoleport.name), escape(consoleport.name),
@@ -983,9 +1089,8 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.change_consoleport' permission_required = 'dcim.change_consoleport'
form = forms.ConsoleConnectionImportForm model_form = forms.ConsoleConnectionCSVForm
table = tables.ConsoleConnectionTable table = tables.ConsoleConnectionTable
template_name = 'dcim/console_connections_import.html'
default_return_url = 'dcim:console_connections_list' default_return_url = 'dcim:console_connections_list'
@@ -993,7 +1098,7 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
# Console server ports # Console server ports
# #
class ConsoleServerPortAddView(PermissionRequiredMixin, ComponentCreateView): class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverport' permission_required = 'dcim.add_consoleserverport'
parent_model = Device parent_model = Device
parent_field = 'device' parent_field = 'device'
@@ -1014,7 +1119,7 @@ def consoleserverport_connect(request, pk):
consoleport.cs_port = consoleserverport consoleport.cs_port = consoleserverport
consoleport.connection_status = form.cleaned_data['connection_status'] consoleport.connection_status = form.cleaned_data['connection_status']
consoleport.save() consoleport.save()
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format( msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(), consoleport.device.get_absolute_url(),
escape(consoleport.device), escape(consoleport.device),
escape(consoleport.name), escape(consoleport.name),
@@ -1028,9 +1133,9 @@ def consoleserverport_connect(request, pk):
else: else:
form = forms.ConsoleServerPortConnectionForm(initial={ form = forms.ConsoleServerPortConnectionForm(initial={
'site': request.GET.get('site', consoleserverport.device.site), 'site': request.GET.get('site'),
'rack': request.GET.get('rack', None), 'rack': request.GET.get('rack'),
'device': request.GET.get('device', None), 'device': request.GET.get('device'),
'connection_status': CONNECTION_STATUS_CONNECTED, 'connection_status': CONNECTION_STATUS_CONNECTED,
}) })
@@ -1048,7 +1153,7 @@ def consoleserverport_disconnect(request, pk):
if not hasattr(consoleserverport, 'connected_console'): if not hasattr(consoleserverport, 'connected_console'):
messages.warning( messages.warning(
request, u"Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport) request, "Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport)
) )
return redirect('dcim:device', pk=consoleserverport.device.pk) return redirect('dcim:device', pk=consoleserverport.device.pk)
@@ -1059,7 +1164,7 @@ def consoleserverport_disconnect(request, pk):
consoleport.cs_port = None consoleport.cs_port = None
consoleport.connection_status = None consoleport.connection_status = None
consoleport.save() consoleport.save()
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format( msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(), consoleport.device.get_absolute_url(),
escape(consoleport.device), escape(consoleport.device),
escape(consoleport.name), escape(consoleport.name),
@@ -1092,6 +1197,15 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
model = ConsoleServerPort 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): class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverport' permission_required = 'dcim.delete_consoleserverport'
cls = ConsoleServerPort cls = ConsoleServerPort
@@ -1102,7 +1216,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Power ports # Power ports
# #
class PowerPortAddView(PermissionRequiredMixin, ComponentCreateView): class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerport' permission_required = 'dcim.add_powerport'
parent_model = Device parent_model = Device
parent_field = 'device' parent_field = 'device'
@@ -1120,7 +1234,7 @@ def powerport_connect(request, pk):
form = forms.PowerPortConnectionForm(request.POST, instance=powerport) form = forms.PowerPortConnectionForm(request.POST, instance=powerport)
if form.is_valid(): if form.is_valid():
powerport = form.save() powerport = form.save()
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format( msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(), powerport.device.get_absolute_url(),
escape(powerport.device), escape(powerport.device),
escape(powerport.name), escape(powerport.name),
@@ -1134,9 +1248,9 @@ def powerport_connect(request, pk):
else: else:
form = forms.PowerPortConnectionForm(instance=powerport, initial={ form = forms.PowerPortConnectionForm(instance=powerport, initial={
'site': request.GET.get('site', powerport.device.site), 'site': request.GET.get('site'),
'rack': request.GET.get('rack', None), 'rack': request.GET.get('rack'),
'pdu': request.GET.get('pdu', None), 'pdu': request.GET.get('pdu'),
'connection_status': CONNECTION_STATUS_CONNECTED, 'connection_status': CONNECTION_STATUS_CONNECTED,
}) })
@@ -1154,7 +1268,7 @@ def powerport_disconnect(request, pk):
if not powerport.power_outlet: if not powerport.power_outlet:
messages.warning( messages.warning(
request, u"Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport) request, "Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport)
) )
return redirect('dcim:device', pk=powerport.device.pk) return redirect('dcim:device', pk=powerport.device.pk)
@@ -1165,7 +1279,7 @@ def powerport_disconnect(request, pk):
powerport.power_outlet = None powerport.power_outlet = None
powerport.connection_status = None powerport.connection_status = None
powerport.save() powerport.save()
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format( msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(), powerport.device.get_absolute_url(),
escape(powerport.device), escape(powerport.device),
escape(powerport.name), escape(powerport.name),
@@ -1206,9 +1320,8 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.change_powerport' permission_required = 'dcim.change_powerport'
form = forms.PowerConnectionImportForm model_form = forms.PowerConnectionCSVForm
table = tables.PowerConnectionTable table = tables.PowerConnectionTable
template_name = 'dcim/power_connections_import.html'
default_return_url = 'dcim:power_connections_list' default_return_url = 'dcim:power_connections_list'
@@ -1216,7 +1329,7 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
# Power outlets # Power outlets
# #
class PowerOutletAddView(PermissionRequiredMixin, ComponentCreateView): class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlet' permission_required = 'dcim.add_poweroutlet'
parent_model = Device parent_model = Device
parent_field = 'device' parent_field = 'device'
@@ -1237,7 +1350,7 @@ def poweroutlet_connect(request, pk):
powerport.power_outlet = poweroutlet powerport.power_outlet = poweroutlet
powerport.connection_status = form.cleaned_data['connection_status'] powerport.connection_status = form.cleaned_data['connection_status']
powerport.save() powerport.save()
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format( msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(), powerport.device.get_absolute_url(),
escape(powerport.device), escape(powerport.device),
escape(powerport.name), escape(powerport.name),
@@ -1251,9 +1364,9 @@ def poweroutlet_connect(request, pk):
else: else:
form = forms.PowerOutletConnectionForm(initial={ form = forms.PowerOutletConnectionForm(initial={
'site': request.GET.get('site', poweroutlet.device.site), 'site': request.GET.get('site'),
'rack': request.GET.get('rack', None), 'rack': request.GET.get('rack'),
'device': request.GET.get('device', None), 'device': request.GET.get('device'),
'connection_status': CONNECTION_STATUS_CONNECTED, 'connection_status': CONNECTION_STATUS_CONNECTED,
}) })
@@ -1271,7 +1384,7 @@ def poweroutlet_disconnect(request, pk):
if not hasattr(poweroutlet, 'connected_port'): if not hasattr(poweroutlet, 'connected_port'):
messages.warning( messages.warning(
request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet) request, "Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet)
) )
return redirect('dcim:device', pk=poweroutlet.device.pk) return redirect('dcim:device', pk=poweroutlet.device.pk)
@@ -1282,7 +1395,7 @@ def poweroutlet_disconnect(request, pk):
powerport.power_outlet = None powerport.power_outlet = None
powerport.connection_status = None powerport.connection_status = None
powerport.save() powerport.save()
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format( msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(), powerport.device.get_absolute_url(),
escape(powerport.device), escape(powerport.device),
escape(powerport.name), escape(powerport.name),
@@ -1315,6 +1428,17 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
model = PowerOutlet 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): class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlet' permission_required = 'dcim.delete_poweroutlet'
cls = PowerOutlet cls = PowerOutlet
@@ -1325,7 +1449,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Interfaces # Interfaces
# #
class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView): class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interface' permission_required = 'dcim.add_interface'
parent_model = Device parent_model = Device
parent_field = 'device' parent_field = 'device'
@@ -1345,6 +1469,18 @@ class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
model = Interface 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): class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface' permission_required = 'dcim.change_interface'
cls = Interface cls = Interface
@@ -1363,7 +1499,7 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device bays # Device bays
# #
class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView): class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebay' permission_required = 'dcim.add_devicebay'
parent_model = Device parent_model = Device
parent_field = 'device' parent_field = 'device'
@@ -1396,7 +1532,7 @@ def devicebay_populate(request, pk):
device_bay.save() device_bay.save()
if not form.errors: 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) return redirect('dcim:device', pk=device_bay.device.pk)
else: else:
@@ -1420,7 +1556,7 @@ def devicebay_depopulate(request, pk):
removed_device = device_bay.installed_device removed_device = device_bay.installed_device
device_bay.installed_device = None device_bay.installed_device = None
device_bay.save() 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) return redirect('dcim:device', pk=device_bay.device.pk)
else: else:
@@ -1483,11 +1619,11 @@ class DeviceBulkAddComponentView(View):
else: else:
for field, errors in component_form.errors.as_data().items(): for field, errors in component_form.errors.as_data().items():
for e in errors: 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: if not form.errors:
self.model.objects.bulk_create(new_components) 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']) len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk'])
)) ))
return redirect('dcim:device_list') return redirect('dcim:device_list')
@@ -1497,7 +1633,7 @@ class DeviceBulkAddComponentView(View):
selected_devices = Device.objects.filter(pk__in=pk_list) selected_devices = Device.objects.filter(pk__in=pk_list)
if not selected_devices: 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 redirect('dcim:device_list')
return render(request, 'dcim/device_bulk_add_component.html', { return render(request, 'dcim/device_bulk_add_component.html', {
@@ -1559,7 +1695,7 @@ def interfaceconnection_add(request, pk):
if form.is_valid(): if form.is_valid():
interfaceconnection = form.save() interfaceconnection = form.save()
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format( msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
interfaceconnection.interface_a.device.get_absolute_url(), interfaceconnection.interface_a.device.get_absolute_url(),
escape(interfaceconnection.interface_a.device), escape(interfaceconnection.interface_a.device),
escape(interfaceconnection.interface_a.name), escape(interfaceconnection.interface_a.name),
@@ -1583,11 +1719,11 @@ def interfaceconnection_add(request, pk):
else: else:
form = forms.InterfaceConnectionForm(device, initial={ form = forms.InterfaceConnectionForm(device, initial={
'interface_a': request.GET.get('interface_a', None), 'interface_a': request.GET.get('interface_a'),
'site_b': request.GET.get('site_b', device.site), 'site_b': request.GET.get('site_b'),
'rack_b': request.GET.get('rack_b', None), 'rack_b': request.GET.get('rack_b'),
'device_b': request.GET.get('device_b', None), 'device_b': request.GET.get('device_b'),
'interface_b': request.GET.get('interface_b', None), 'interface_b': request.GET.get('interface_b'),
}) })
return render(request, 'dcim/interfaceconnection_edit.html', { return render(request, 'dcim/interfaceconnection_edit.html', {
@@ -1607,7 +1743,7 @@ def interfaceconnection_delete(request, pk):
form = forms.InterfaceConnectionDeletionForm(request.POST) form = forms.InterfaceConnectionDeletionForm(request.POST)
if form.is_valid(): if form.is_valid():
interfaceconnection.delete() interfaceconnection.delete()
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format( msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
interfaceconnection.interface_a.device.get_absolute_url(), interfaceconnection.interface_a.device.get_absolute_url(),
escape(interfaceconnection.interface_a.device), escape(interfaceconnection.interface_a.device),
escape(interfaceconnection.interface_a.name), escape(interfaceconnection.interface_a.name),
@@ -1643,9 +1779,8 @@ def interfaceconnection_delete(request, pk):
class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.change_interface' permission_required = 'dcim.change_interface'
form = forms.InterfaceConnectionImportForm model_form = forms.InterfaceConnectionCSVForm
table = tables.InterfaceConnectionTable table = tables.InterfaceConnectionTable
template_name = 'dcim/interface_connections_import.html'
default_return_url = 'dcim:interface_connections_list' default_return_url = 'dcim:interface_connections_list'

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe

View File

@@ -1,10 +1,15 @@
from django.contrib.contenttypes.models import ContentType from __future__ import unicode_literals
from django.db import transaction from datetime import datetime
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue 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,
)
# #
@@ -23,16 +28,34 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
for field_name, value in data.items(): for field_name, value in data.items():
cf = custom_fields[field_name]
# Validate custom field name # Validate custom field name
if field_name not in custom_fields: if field_name not in custom_fields:
raise ValidationError(u"Invalid custom field for {} objects: {}".format(content_type, field_name)) 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 # Validate selected choice
cf = custom_fields[field_name]
if cf.type == CF_TYPE_SELECT: 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()] valid_choices = [c.pk for c in cf.choices.all()]
if value not in valid_choices: if value not in valid_choices:
raise ValidationError(u"Invalid choice ({}) for field {}".format(value, field_name)) raise ValidationError("Invalid choice for field {}: {}".format(field_name, value))
# Check for missing required fields # Check for missing required fields
missing_fields = [] missing_fields = []
@@ -40,7 +63,7 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
if field.required and field_name not in data: if field.required and field_name not in data:
missing_fields.append(field_name) missing_fields.append(field_name)
if missing_fields: if missing_fields:
raise ValidationError(u"Missing required fields: {}".format(u", ".join(missing_fields))) raise ValidationError("Missing required fields: {}".format(u", ".join(missing_fields)))
return data return data
@@ -85,7 +108,7 @@ class CustomFieldModelSerializer(serializers.ModelSerializer):
field=custom_field, field=custom_field,
obj_type=content_type, obj_type=content_type,
obj_id=instance.pk, obj_id=instance.pk,
defaults={'serialized_value': value}, defaults={'serialized_value': custom_field.serialize_value(value)},
) )
def create(self, validated_data): def create(self, validated_data):

View File

@@ -1,7 +1,9 @@
from rest_framework import serializers from __future__ import unicode_literals
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
from dcim.models import Device, Rack, Site from dcim.models import Device, Rack, Site
from extras.models import ( from extras.models import (

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers from rest_framework import routers
from . import views from . import views

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
import django_filters import django_filters
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -30,7 +32,7 @@ class CustomFieldFilter(django_filters.Filter):
pass pass
return queryset.filter( return queryset.filter(
custom_field_values__field__name=self.name, custom_field_values__field__name=self.name,
custom_field_values__serialized_value=value, custom_field_values__serialized_value__icontains=value,
) )

View File

@@ -1,3 +1,4 @@
from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from django import forms from django import forms
@@ -104,7 +105,7 @@ class CustomFieldForm(forms.ModelForm):
obj_id=self.instance.pk) obj_id=self.instance.pk)
except CustomFieldValue.DoesNotExist: except CustomFieldValue.DoesNotExist:
# Skip this field if none exists already and its value is empty # 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 continue
cfv = CustomFieldValue( cfv = CustomFieldValue(
field=self.fields[field_name].model, field=self.fields[field_name].model,

View File

@@ -0,0 +1,62 @@
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(<model>) 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 from each app
for app in APPS:
self.django_models[app] = []
app_models = sys.modules['{}.models'.format(app)]
for name in dir(app_models):
model = getattr(app_models, name)
try:
if issubclass(model, Model):
namespace[name] = model
self.django_models[app].append(name)
except TypeError:
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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from getpass import getpass from getpass import getpass
from ncclient.transport.errors import AuthenticationError from ncclient.transport.errors import AuthenticationError
from paramiko import AuthenticationException from paramiko import AuthenticationException

View File

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

View File

@@ -1,3 +1,4 @@
from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from datetime import date from datetime import date
import graphviz import graphviz
@@ -138,7 +139,11 @@ class CustomField(models.Model):
if self.type == CF_TYPE_BOOLEAN: if self.type == CF_TYPE_BOOLEAN:
return str(int(bool(value))) return str(int(bool(value)))
if self.type == CF_TYPE_DATE: if self.type == CF_TYPE_DATE:
# Could be date/datetime object or string
try:
return value.strftime('%Y-%m-%d') return value.strftime('%Y-%m-%d')
except AttributeError:
return value
if self.type == CF_TYPE_SELECT: if self.type == CF_TYPE_SELECT:
# Could be ModelChoiceField or TypedChoiceField # Could be ModelChoiceField or TypedChoiceField
return str(value.id) if hasattr(value, 'id') else str(value) return str(value.id) if hasattr(value, 'id') else str(value)
@@ -175,7 +180,7 @@ class CustomFieldValue(models.Model):
unique_together = ['field', 'obj_type', 'obj_id'] unique_together = ['field', 'obj_type', 'obj_id']
def __str__(self): def __str__(self):
return u'{} {}'.format(self.obj, self.field) return '{} {}'.format(self.obj, self.field)
@property @property
def value(self): def value(self):
@@ -269,7 +274,7 @@ class ExportTemplate(models.Model):
] ]
def __str__(self): 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): def to_response(self, context_dict, filename):
""" """
@@ -366,7 +371,8 @@ class TopologyMap(models.Model):
# Add all circuits to the graph # Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices): for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
peer_termination = termination.get_peer_termination() peer_termination = termination.get_peer_termination()
if peer_termination is not None and peer_termination.interface.device in devices: 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') graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
return graph.pipe(format=img_format) return graph.pipe(format=img_format)
@@ -381,13 +387,13 @@ def image_upload(instance, filename):
path = 'image-attachments/' path = 'image-attachments/'
# Rename the file to the provided name, if any. Attempt to preserve the file extension. # Rename the file to the provided name, if any. Attempt to preserve the file extension.
extension = filename.rsplit('.')[-1] extension = filename.rsplit('.')[-1].lower()
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']: if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
filename = '.'.join([instance.name, extension]) filename = '.'.join([instance.name, extension])
elif instance.name: elif instance.name:
filename = instance.name filename = instance.name
return u'{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename) return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
@python_2_unicode_compatible @python_2_unicode_compatible
@@ -503,8 +509,8 @@ class UserAction(models.Model):
def __str__(self): def __str__(self):
if self.message: if self.message:
return u'{} {}'.format(self.user, self.message) return '{} {}'.format(self.user, self.message)
return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type) return '{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
def icon(self): def icon(self):
if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]: if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]:

View File

@@ -1,8 +1,10 @@
from __future__ import unicode_literals
import re
import time
from ncclient import manager from ncclient import manager
import paramiko import paramiko
import re
import xmltodict import xmltodict
import time
CONNECT_TIMEOUT = 5 # seconds CONNECT_TIMEOUT = 5 # seconds

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase

View File

@@ -1,3 +1,4 @@
from __future__ import unicode_literals
from datetime import date from datetime import date
from rest_framework import status from rest_framework import status
@@ -9,7 +10,6 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from dcim.models import Site from dcim.models import Site
from extras.models import ( from extras.models import (
CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CustomField, CustomFieldValue, CustomFieldChoice, CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE,
CF_TYPE_SELECT, CF_TYPE_URL, CF_TYPE_SELECT, CF_TYPE_URL,

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from extras import views from extras import views

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
@@ -23,7 +25,7 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_imageattachment' permission_required = 'extras.delete_imageattachment'
model = ImageAttachment model = ImageAttachment
def get_return_url(self, request, imageattachment): def get_return_url(self, request, imageattachment):

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
@@ -135,7 +137,7 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer):
# Validate uniqueness of name and slug if a site has been assigned. # Validate uniqueness of name and slug if a site has been assigned.
if data.get('site', None): if data.get('site', None):
for field in ['name', 'slug']: for field in ['name', 'slug']:
validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('site', field)) validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field))
validator.set_context(self) validator.set_context(self)
validator(data) validator(data)

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers from rest_framework import routers
from . import views from . import views

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.apps import AppConfig from django.apps import AppConfig

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from netaddr import IPNetwork from netaddr import IPNetwork
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
import django_filters import django_filters
from netaddr import IPNetwork from netaddr import IPNetwork
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
@@ -8,7 +10,6 @@ from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import ( from .models import (
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
VLAN_STATUS_CHOICES, VLANGroup, VRF, VLAN_STATUS_CHOICES, VLANGroup, VRF,
@@ -84,7 +85,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
try: try:
prefix = str(IPNetwork(value.strip()).cidr) prefix = str(IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix) qs_filter |= Q(prefix__net_contains_or_equals=prefix)
except AddrFormatError: except (AddrFormatError, ValueError):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
@@ -171,7 +172,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
try: try:
prefix = str(IPNetwork(value.strip()).cidr) prefix = str(IPNetwork(value.strip()).cidr)
qs_filter |= Q(prefix__net_contains_or_equals=prefix) qs_filter |= Q(prefix__net_contains_or_equals=prefix)
except AddrFormatError: except (AddrFormatError, ValueError):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
@@ -182,7 +183,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
try: try:
query = str(IPNetwork(value).cidr) query = str(IPNetwork(value).cidr)
return queryset.filter(prefix__net_contained_or_equal=query) return queryset.filter(prefix__net_contained_or_equal=query)
except AddrFormatError: except (AddrFormatError, ValueError):
return queryset.none() return queryset.none()
def filter_mask_length(self, queryset, name, value): def filter_mask_length(self, queryset, name, value):
@@ -258,7 +259,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
try: try:
ipaddress = str(IPNetwork(value.strip())) ipaddress = str(IPNetwork(value.strip()))
qs_filter |= Q(address__net_host=ipaddress) qs_filter |= Q(address__net_host=ipaddress)
except AddrFormatError: except (AddrFormatError, ValueError):
pass pass
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
@@ -269,7 +270,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
try: try:
query = str(IPNetwork(value.strip()).cidr) query = str(IPNetwork(value.strip()).cidr)
return queryset.filter(address__net_host_contained=query) return queryset.filter(address__net_host_contained=query)
except AddrFormatError: except (AddrFormatError, ValueError):
return queryset.none() return queryset.none()
def filter_mask_length(self, queryset, name, value): def filter_mask_length(self, queryset, name, value):

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from netaddr import IPNetwork, AddrFormatError from netaddr import IPNetwork, AddrFormatError
from django import forms from django import forms

View File

@@ -1,4 +1,7 @@
from __future__ import unicode_literals
from django import forms from django import forms
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count from django.db.models import Count
from dcim.models import Site, Rack, Device, Interface from dcim.models import Site, Rack, Device, Interface
@@ -6,10 +9,10 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
add_blank_choice,
) )
from .models import ( from .models import (
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
VLANGroup, VLAN_STATUS_CHOICES, VRF, VLANGroup, VLAN_STATUS_CHOICES, VRF,
@@ -46,17 +49,23 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class VRFFromCSVForm(forms.ModelForm): class VRFCSVForm(forms.ModelForm):
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, tenant = forms.ModelChoiceField(
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: class Meta:
model = VRF model = VRF
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
help_texts = {
'name': 'VRF name',
class VRFImportForm(BootstrapMixin, BulkImportForm): }
csv = CSVDataField(csv_form=VRFFromCSVForm)
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -114,19 +123,21 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
} }
class AggregateFromCSVForm(forms.ModelForm): class AggregateCSVForm(forms.ModelForm):
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), to_field_name='name', rir = forms.ModelChoiceField(
error_messages={'invalid_choice': 'RIR not found.'}) queryset=RIR.objects.all(),
to_field_name='name',
help_text='Name of parent RIR',
error_messages={
'invalid_choice': 'RIR not found.',
}
)
class Meta: class Meta:
model = Aggregate model = Aggregate
fields = ['prefix', 'rir', 'date_added', 'description'] fields = ['prefix', 'rir', 'date_added', 'description']
class AggregateImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=AggregateFromCSVForm)
class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput)
rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR')
@@ -166,13 +177,35 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select( 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'} attrs={'filter-for': 'vlan', 'nullable': 'true'}
) )
) )
vlan = ChainedModelChoiceField( vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(), chains={'site': 'site'}, required=False, label='VLAN', widget=APISelect( queryset=VLAN.objects.all(),
api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name' 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'
) )
) )
@@ -181,72 +214,108 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant'] fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance and instance.vlan is not None:
initial['vlan_group'] = instance.vlan.group
kwargs['initial'] = initial
super(PrefixForm, self).__init__(*args, **kwargs) super(PrefixForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global' self.fields['vrf'].empty_label = 'Global'
class PrefixFromCSVForm(forms.ModelForm): class PrefixCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', vrf = forms.ModelChoiceField(
error_messages={'invalid_choice': 'VRF not found.'}) queryset=VRF.objects.all(),
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, required=False,
error_messages={'invalid_choice': 'Tenant not found.'}) to_field_name='rd',
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', help_text='Route distinguisher of parent VRF',
error_messages={'invalid_choice': 'Site not found.'}) error_messages={
vlan_group_name = forms.CharField(required=False) 'invalid_choice': 'VRF not found.',
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', tenant = forms.ModelChoiceField(
error_messages={'invalid_choice': 'Invalid role.'}) 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=IPADDRESS_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: class Meta:
model = Prefix model = Prefix
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool', fields = [
'description'] 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
]
def clean(self): def clean(self):
super(PrefixFromCSVForm, self).clean() super(PrefixCSVForm, self).clean()
site = self.cleaned_data.get('site') 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') vlan_vid = self.cleaned_data.get('vlan_vid')
vlan_group = None
# Validate VLAN group
if vlan_group_name:
try:
vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
except VLANGroup.DoesNotExist:
if site:
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
else:
self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name))
# Validate VLAN # Validate VLAN
if vlan_vid: if vlan_group and vlan_vid:
try: try:
self.instance.vlan = VLAN.objects.get(site=site, group=vlan_group, vid=vlan_vid) self.instance.vlan = VLAN.objects.get(site=site, group__name=vlan_group, vid=vlan_vid)
except VLAN.DoesNotExist: except VLAN.DoesNotExist:
if site: if site:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site)) raise forms.ValidationError("VLAN {} not found in site {} group {}".format(
elif vlan_group: vlan_vid, site, vlan_group
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for group {}.".format(vlan_vid, vlan_group_name)) ))
elif not vlan_group_name: else:
self.add_error('vlan_vid', "Invalid global VLAN ID ({}).".format(vlan_vid)) raise forms.ValidationError("Global VLAN {} not found in group {}".format(vlan_vid, vlan_group))
except VLAN.MultipleObjectsReturned: except MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid)) raise forms.ValidationError(
"Multiple VLANs with VID {} found in group {}".format(vlan_vid, vlan_group)
def save(self, *args, **kwargs): )
elif vlan_vid:
# Assign Prefix status by name try:
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] self.instance.vlan = VLAN.objects.get(site=site, group__isnull=True, vid=vlan_vid)
except VLAN.DoesNotExist:
return super(PrefixFromCSVForm, self).save(*args, **kwargs) if site:
raise forms.ValidationError("VLAN {} not found in site {}".format(vlan_vid, site))
else:
class PrefixImportForm(BootstrapMixin, BulkImportForm): raise forms.ValidationError("Global VLAN {} not found".format(vlan_vid))
csv = CSVDataField(csv_form=PrefixFromCSVForm) except MultipleObjectsReturned:
raise forms.ValidationError("Multiple VLANs with VID {} found".format(vlan_vid))
class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -267,7 +336,7 @@ def prefix_status_choices():
status_counts = {} status_counts = {}
for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'): for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count'] 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): class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -318,7 +387,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
) )
interface_rack = ChainedModelChoiceField( interface_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'interface_site'}, chains=(
('site', 'interface_site'),
),
required=False, required=False,
label='Rack', label='Rack',
widget=APISelect( widget=APISelect(
@@ -329,7 +400,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
) )
interface_device = ChainedModelChoiceField( interface_device = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains={'site': 'interface_site', 'rack': 'interface_rack'}, chains=(
('site', 'interface_site'),
('rack', 'interface_rack'),
),
required=False, required=False,
label='Device', label='Device',
widget=APISelect( widget=APISelect(
@@ -340,7 +414,9 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
) )
interface = ChainedModelChoiceField( interface = ChainedModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
chains={'device': 'interface_device'}, chains=(
('device', 'interface_device'),
),
required=False, required=False,
widget=APISelect( widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{interface_device}}' api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
@@ -351,34 +427,41 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
required=False, required=False,
label='Site', label='Site',
widget=forms.Select( widget=forms.Select(
attrs={'filter-for': 'nat_device'} attrs={'filter-for': 'nat_rack'}
) )
) )
nat_rack = ChainedModelChoiceField( nat_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
chains={'site': 'nat_site'}, chains=(
('site', 'nat_site'),
),
required=False, required=False,
label='Rack', label='Rack',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/racks/?site_id={{interface_site}}', api_url='/api/dcim/racks/?site_id={{nat_site}}',
display_field='display_name', display_field='display_name',
attrs={'filter-for': 'nat_device', 'nullable': 'true'} attrs={'filter-for': 'nat_device', 'nullable': 'true'}
) )
) )
nat_device = ChainedModelChoiceField( nat_device = ChainedModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
chains={'site': 'nat_site'}, chains=(
('site', 'nat_site'),
('rack', 'nat_rack'),
),
required=False, required=False,
label='Device', label='Device',
widget=APISelect( widget=APISelect(
api_url='/api/dcim/devices/?site_id={{nat_site}}', api_url='/api/dcim/devices/?site_id={{nat_site}}&rack_id={{nat_rack}}',
display_field='display_name', display_field='display_name',
attrs={'filter-for': 'nat_inside'} attrs={'filter-for': 'nat_inside'}
) )
) )
nat_inside = ChainedModelChoiceField( nat_inside = ChainedModelChoiceField(
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
chains={'interface__device': 'nat_device'}, chains=(
('interface__device', 'nat_device'),
),
required=False, required=False,
label='IP Address', label='IP Address',
widget=APISelect( widget=APISelect(
@@ -388,7 +471,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
) )
livesearch = forms.CharField( livesearch = forms.CharField(
required=False, required=False,
label='IP Address', label='Search',
widget=Livesearch( widget=Livesearch(
query_key='q', query_key='q',
query_url='ipam-api:ipaddress-list', query_url='ipam-api:ipaddress-list',
@@ -401,8 +484,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside', 'tenant_group', 'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
'tenant', 'nat_inside', 'tenant_group', 'tenant',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -414,7 +497,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
initial['interface_site'] = instance.interface.device.site initial['interface_site'] = instance.interface.device.site
initial['interface_rack'] = instance.interface.device.rack initial['interface_rack'] = instance.interface.device.rack
initial['interface_device'] = instance.interface.device initial['interface_device'] = instance.interface.device
if instance and instance.nat_inside is not None: if instance and instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device initial['nat_device'] = instance.nat_inside.device
@@ -486,23 +569,55 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['vrf'].empty_label = 'Global' self.fields['vrf'].empty_label = 'Global'
class IPAddressFromCSVForm(forms.ModelForm): class IPAddressCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', vrf = forms.ModelChoiceField(
error_messages={'invalid_choice': 'VRF not found.'}) queryset=VRF.objects.all(),
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, required=False,
error_messages={'invalid_choice': 'Tenant not found.'}) to_field_name='rd',
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES]) help_text='Route distinguisher of the assigned VRF',
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', error_messages={
error_messages={'invalid_choice': 'Device not found.'}) 'invalid_choice': 'VRF not found.',
interface_name = forms.CharField(required=False) }
is_primary = forms.BooleanField(required=False) )
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'
)
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: class Meta:
model = IPAddress model = IPAddress
fields = ['address', 'vrf', 'tenant', 'status_name', 'device', 'interface_name', 'is_primary', 'description'] fields = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description']
def clean(self): def clean(self):
super(IPAddressCSVForm, self).clean()
device = self.cleaned_data.get('device') device = self.cleaned_data.get('device')
interface_name = self.cleaned_data.get('interface_name') interface_name = self.cleaned_data.get('interface_name')
is_primary = self.cleaned_data.get('is_primary') is_primary = self.cleaned_data.get('is_primary')
@@ -510,39 +625,39 @@ class IPAddressFromCSVForm(forms.ModelForm):
# Validate interface # Validate interface
if device and interface_name: if device and interface_name:
try: try:
Interface.objects.get(device=device, name=interface_name) self.instance.interface = Interface.objects.get(device=device, name=interface_name)
except Interface.DoesNotExist: 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: 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: 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 # Validate is_primary
if is_primary and not device: 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): 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 # Set interface
if self.cleaned_data['device'] and self.cleaned_data['interface_name']: if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
self.instance.interface = Interface.objects.get(device=self.cleaned_data['device'], self.instance.interface = Interface.objects.get(
name=self.cleaned_data['interface_name']) device=self.cleaned_data['device'],
name=self.cleaned_data['interface_name']
)
ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs)
# Set as primary for device # Set as primary for device
if self.cleaned_data['is_primary']: if self.cleaned_data['is_primary']:
device = self.cleaned_data['device']
if self.instance.address.version == 4: 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: 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) return ipaddress
class IPAddressImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=IPAddressFromCSVForm)
class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -560,7 +675,7 @@ def ipaddress_status_choices():
status_counts = {} status_counts = {}
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'): for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count'] 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]
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -612,13 +727,16 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm): class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False,
widget=forms.Select( widget=forms.Select(
attrs={'filter-for': 'group', 'nullable': 'true'} attrs={'filter-for': 'group', 'nullable': 'true'}
) )
) )
group = ChainedModelChoiceField( group = ChainedModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
chains={'site': 'site'}, chains=(
('site', 'site'),
),
required=False, required=False,
label='Group', label='Group',
widget=APISelect( widget=APISelect(
@@ -639,56 +757,67 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class VLANFromCSVForm(forms.ModelForm): class VLANCSVForm(forms.ModelForm):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, to_field_name='name', queryset=Site.objects.all(),
error_messages={'invalid_choice': 'Site not found.'} required=False,
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 VLAN group',
required=False
) )
group_name = forms.CharField(required=False)
tenant = forms.ModelChoiceField( tenant = forms.ModelChoiceField(
Tenant.objects.all(), to_field_name='name', required=False, queryset=Tenant.objects.all(),
error_messages={'invalid_choice': 'Tenant not found.'} 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'
) )
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
role = forms.ModelChoiceField( role = forms.ModelChoiceField(
queryset=Role.objects.all(), required=False, to_field_name='name', queryset=Role.objects.all(),
error_messages={'invalid_choice': 'Invalid role.'} required=False,
to_field_name='name',
help_text='Functional role',
error_messages={
'invalid_choice': 'Invalid role.',
}
) )
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['site', 'group_name', '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 clean(self): def clean(self):
super(VLANFromCSVForm, self).clean() super(VLANCSVForm, self).clean()
# Validate VLANGroup site = self.cleaned_data.get('site')
group_name = self.cleaned_data.get('group_name') group_name = self.cleaned_data.get('group_name')
# Validate VLAN group
if group_name: if group_name:
try: try:
VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name) self.instance.group = VLANGroup.objects.get(site=site, name=group_name)
except VLANGroup.DoesNotExist: except VLANGroup.DoesNotExist:
self.add_error('group_name', "Invalid VLAN group {}.".format(group_name)) if site:
raise forms.ValidationError("VLAN group {} not found for site {}".format(group_name, site))
def save(self, *args, **kwargs): else:
raise forms.ValidationError("Global VLAN group {} not found".format(group_name))
vlan = super(VLANFromCSVForm, self).save(commit=False)
# Assign VLANGroup by site and name
if self.cleaned_data['group_name']:
vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
# Assign VLAN status by name
vlan.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
if kwargs.get('commit'):
vlan.save()
return vlan
class VLANImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=VLANFromCSVForm)
class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -708,7 +837,7 @@ def vlan_status_choices():
status_counts = {} status_counts = {}
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'): for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count'] 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): class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.db.models import Lookup, Transform, IntegerField from django.db.models import Lookup, Transform, IntegerField
from django.db.models.lookups import BuiltinLookup from django.db.models.lookups import BuiltinLookup

View File

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

View File

@@ -1,4 +1,6 @@
from netaddr import IPNetwork, cidr_merge from __future__ import unicode_literals
from netaddr import IPNetwork, IPSet
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
@@ -15,7 +17,6 @@ from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet from utilities.sql import NullsFirstQuerySet
from utilities.utils import csv_format from utilities.utils import csv_format
from .fields import IPNetworkField, IPAddressField from .fields import IPNetworkField, IPAddressField
@@ -88,6 +89,8 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
description = models.CharField(max_length=100, blank=True) description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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: class Meta:
ordering = ['name'] ordering = ['name']
verbose_name = 'VRF' verbose_name = 'VRF'
@@ -145,6 +148,8 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
description = models.CharField(max_length=100, blank=True) description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = ['prefix', 'rir', 'date_added', 'description']
class Meta: class Meta:
ordering = ['family', 'prefix'] ordering = ['family', 'prefix']
@@ -199,15 +204,11 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
def get_utilization(self): 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)) queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
# Remove overlapping prefixes from list of children child_prefixes = IPSet([p.prefix for p in queryset])
networks = cidr_merge([c.prefix for c in child_prefixes]) return int(float(child_prefixes.size) / self.prefix.size * 100)
children_size = float(0)
for p in networks:
children_size += p.size
return int(children_size / self.prefix.size * 100)
@python_2_unicode_compatible @python_2_unicode_compatible
@@ -296,6 +297,10 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
objects = PrefixQuerySet.as_manager() objects = PrefixQuerySet.as_manager()
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
]
class Meta: class Meta:
ordering = ['vrf', 'family', 'prefix'] ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes' verbose_name_plural = 'prefixes'
@@ -306,9 +311,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:prefix', args=[self.pk]) 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): def clean(self):
if self.prefix: if self.prefix:
@@ -356,6 +358,30 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
self.description, 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_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 = 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 @property
def new_subnet(self): def new_subnet(self):
if self.family == 4: if self.family == 4:
@@ -367,9 +393,6 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1))
return None return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
class IPAddressManager(models.Manager): class IPAddressManager(models.Manager):
@@ -413,6 +436,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
objects = IPAddressManager() objects = IPAddressManager()
csv_headers = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description']
class Meta: class Meta:
ordering = ['family', 'address'] ordering = ['family', 'address']
verbose_name = 'IP address' verbose_name = 'IP address'
@@ -451,11 +476,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
def to_csv(self): def to_csv(self):
# Determine if this IP is primary for a Device # Determine if this IP is primary for a Device
is_primary = False
if self.family == 4 and getattr(self, 'primary_ip4_for', False): if self.family == 4 and getattr(self, 'primary_ip4_for', False):
is_primary = True is_primary = True
elif self.family == 6 and getattr(self, 'primary_ip6_for', False): elif self.family == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True is_primary = True
else:
is_primary = False
return csv_format([ return csv_format([
self.address, self.address,
@@ -497,9 +523,7 @@ class VLANGroup(models.Model):
verbose_name_plural = 'VLAN groups' verbose_name_plural = 'VLAN groups'
def __str__(self): def __str__(self):
if self.site is None:
return self.name return self.name
return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self): def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@@ -528,6 +552,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
description = models.CharField(max_length=100, blank=True) description = models.CharField(max_length=100, blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') 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: class Meta:
ordering = ['site', 'group', 'vid'] ordering = ['site', 'group', 'vid']
unique_together = [ unique_together = [
@@ -566,7 +592,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@property @property
def display_name(self): def display_name(self):
if self.vid and self.name: if self.vid and self.name:
return u"{} ({})".format(self.vid, self.name) return "{} ({})".format(self.vid, self.name)
return None return None
def get_status_class(self): def get_status_class(self):
@@ -593,4 +619,4 @@ class Service(CreatedUpdatedModel):
unique_together = ['device', 'protocol', 'port'] unique_together = ['device', 'protocol', 'port']
def __str__(self): def __str__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())

View File

@@ -1,8 +1,9 @@
from __future__ import unicode_literals
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from utilities.tables import BaseTable, SearchTable, ToggleColumn from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
@@ -33,7 +34,7 @@ RIR_ACTIONS = """
UTILIZATION_GRAPH = """ UTILIZATION_GRAPH = """
{% load helpers %} {% load helpers %}
{% utilization_graph value %} {% if record.pk %}{% utilization_graph value %}{% else %}&mdash;{% endif %}
""" """
ROLE_ACTIONS = """ ROLE_ACTIONS = """
@@ -70,9 +71,9 @@ IPADDRESS_LINK = """
{% if record.pk %} {% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a> <a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %} {% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a> <a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %} {% else %}
{{ record.0 }} {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
{% endif %} {% endif %}
""" """
@@ -240,6 +241,7 @@ class PrefixTable(BaseTable):
prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}}) prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
status = tables.TemplateColumn(STATUS_LABEL) status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
tenant = tables.TemplateColumn(TENANT_LINK) tenant = tables.TemplateColumn(TENANT_LINK)
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
@@ -247,7 +249,7 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Prefix model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description') fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description')
row_attrs = { row_attrs = {
'class': lambda record: 'success' if not record.pk else '', 'class': lambda record: 'success' if not record.pk else '',
} }

View File

@@ -1,5 +1,6 @@
from netaddr import IPNetwork from __future__ import unicode_literals
from netaddr import IPNetwork
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase

View File

@@ -1,9 +1,11 @@
from __future__ import unicode_literals
import netaddr import netaddr
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from ipam.models import IPAddress, Prefix, VRF from ipam.models import IPAddress, Prefix, VRF
from django.core.exceptions import ValidationError
class TestPrefix(TestCase): class TestPrefix(TestCase):

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from . import views from . import views
@@ -8,71 +10,71 @@ urlpatterns = [
# VRFs # VRFs
url(r'^vrfs/$', views.VRFListView.as_view(), name='vrf_list'), 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/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), 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/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
url(r'^vrfs/(?P<pk>\d+)/$', views.vrf, name='vrf'), url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'), url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'), url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
# RIRs # RIRs
url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), 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/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
# Aggregates # Aggregates
url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'), 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/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), 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/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
url(r'^aggregates/(?P<pk>\d+)/$', views.aggregate, name='aggregate'), url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'), url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'), url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
# Roles # Roles
url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), 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/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
# Prefixes # Prefixes
url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'), 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/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), 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/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
url(r'^prefixes/(?P<pk>\d+)/$', views.prefix, name='prefix'), url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'), url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'), url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.prefix_ipaddresses, name='prefix_ipaddresses'), url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
# IP addresses # IP addresses
url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'), 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/add/$', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkAddView.as_view(), name='ipaddress_bulk_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/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/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/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'), url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
# VLAN groups # VLAN groups
url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'), 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/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
# VLANs # VLANs
url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), 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/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), 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/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
url(r'^vlans/(?P<pk>\d+)/$', views.vlan, name='vlan'), url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
import netaddr import netaddr
@@ -6,13 +8,13 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, Q from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.urls import reverse from django.urls import reverse
from django.views.generic import View
from dcim.models import Device from dcim.models import Device
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
) )
from . import filters, forms, tables from . import filters, forms, tables
from .models import ( from .models import (
Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role,
@@ -96,7 +98,9 @@ class VRFListView(ObjectListView):
template_name = 'ipam/vrf_list.html' template_name = 'ipam/vrf_list.html'
def vrf(request, pk): class VRFView(View):
def get(self, request, pk):
vrf = get_object_or_404(VRF.objects.all(), pk=pk) vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefix_table = tables.PrefixBriefTable( prefix_table = tables.PrefixBriefTable(
@@ -110,14 +114,18 @@ def vrf(request, pk):
}) })
class VRFEditView(PermissionRequiredMixin, ObjectEditView): class VRFCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vrf' permission_required = 'ipam.add_vrf'
model = VRF model = VRF
form_class = forms.VRFForm form_class = forms.VRFForm
template_name = 'ipam/vrf_edit.html' template_name = 'ipam/vrf_edit.html'
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
class VRFEditView(VRFCreateView):
permission_required = 'ipam.change_vrf'
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf' permission_required = 'ipam.delete_vrf'
model = VRF model = VRF
@@ -126,9 +134,8 @@ class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vrf' permission_required = 'ipam.add_vrf'
form = forms.VRFImportForm model_form = forms.VRFCSVForm
table = tables.VRFTable table = tables.VRFTable
template_name = 'ipam/vrf_import.html'
default_return_url = 'ipam:vrf_list' default_return_url = 'ipam:vrf_list'
@@ -236,8 +243,8 @@ class RIRListView(ObjectListView):
} }
class RIREditView(PermissionRequiredMixin, ObjectEditView): class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_rir' permission_required = 'ipam.add_rir'
model = RIR model = RIR
form_class = forms.RIRForm form_class = forms.RIRForm
@@ -245,6 +252,10 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
return reverse('ipam:rir_list') return reverse('ipam:rir_list')
class RIREditView(RIRCreateView):
permission_required = 'ipam.change_rir'
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_rir' permission_required = 'ipam.delete_rir'
cls = RIR cls = RIR
@@ -281,13 +292,20 @@ class AggregateListView(ObjectListView):
} }
def aggregate(request, pk): class AggregateView(View):
def get(self, request, pk):
aggregate = get_object_or_404(Aggregate, pk=pk) aggregate = get_object_or_404(Aggregate, pk=pk)
# Find all child prefixes contained by this aggregate # Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))\ child_prefixes = Prefix.objects.filter(
.select_related('site', 'role').annotate_depth(limit=0) 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) child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
prefix_table = tables.PrefixTable(child_prefixes) prefix_table = tables.PrefixTable(child_prefixes)
@@ -314,14 +332,18 @@ def aggregate(request, pk):
}) })
class AggregateEditView(PermissionRequiredMixin, ObjectEditView): class AggregateCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_aggregate' permission_required = 'ipam.add_aggregate'
model = Aggregate model = Aggregate
form_class = forms.AggregateForm form_class = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html' template_name = 'ipam/aggregate_edit.html'
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
class AggregateEditView(AggregateCreateView):
permission_required = 'ipam.change_aggregate'
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView): class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate' permission_required = 'ipam.delete_aggregate'
model = Aggregate model = Aggregate
@@ -330,9 +352,8 @@ class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_aggregate' permission_required = 'ipam.add_aggregate'
form = forms.AggregateImportForm model_form = forms.AggregateCSVForm
table = tables.AggregateTable table = tables.AggregateTable
template_name = 'ipam/aggregate_import.html'
default_return_url = 'ipam:aggregate_list' default_return_url = 'ipam:aggregate_list'
@@ -362,8 +383,8 @@ class RoleListView(ObjectListView):
template_name = 'ipam/role_list.html' template_name = 'ipam/role_list.html'
class RoleEditView(PermissionRequiredMixin, ObjectEditView): class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_role' permission_required = 'ipam.add_role'
model = Role model = Role
form_class = forms.RoleForm form_class = forms.RoleForm
@@ -371,6 +392,10 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('ipam:role_list') return reverse('ipam:role_list')
class RoleEditView(RoleCreateView):
permission_required = 'ipam.change_role'
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_role' permission_required = 'ipam.delete_role'
cls = Role cls = Role
@@ -394,7 +419,9 @@ class PrefixListView(ObjectListView):
return self.queryset.annotate_depth(limit=limit) return self.queryset.annotate_depth(limit=limit)
def prefix(request, pk): class PrefixView(View):
def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.select_related( prefix = get_object_or_404(Prefix.objects.select_related(
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role' 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
@@ -406,25 +433,38 @@ def prefix(request, pk):
aggregate = None aggregate = None
# Count child IP addresses # Count child IP addresses
ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\ ipaddress_count = IPAddress.objects.filter(
.count() vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix)
).count()
# Parent prefixes table # Parent prefixes table
parent_prefixes = Prefix.objects.filter(Q(vrf=prefix.vrf) | Q(vrf__isnull=True))\ parent_prefixes = Prefix.objects.filter(
.filter(prefix__net_contains=str(prefix.prefix))\ Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
.select_related('site', 'role').annotate_depth() ).filter(
prefix__net_contains=str(prefix.prefix)
).select_related(
'site', 'role'
).annotate_depth()
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes) parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefix_table.exclude = ('vrf',) parent_prefix_table.exclude = ('vrf',)
# Duplicate prefixes table # Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\ duplicate_prefixes = Prefix.objects.filter(
.select_related('site', 'role') 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 = tables.PrefixBriefTable(list(duplicate_prefixes))
duplicate_prefix_table.exclude = ('vrf',) duplicate_prefix_table.exclude = ('vrf',)
# Child prefixes table # Child prefixes table
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\ child_prefixes = Prefix.objects.filter(
.select_related('site', 'role').annotate_depth(limit=0) vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
).select_related(
'site', 'role'
).annotate_depth(limit=0)
if child_prefixes: if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixTable(child_prefixes) child_prefix_table = tables.PrefixTable(child_prefixes)
@@ -456,52 +496,18 @@ def prefix(request, pk):
}) })
class PrefixEditView(PermissionRequiredMixin, ObjectEditView): class PrefixIPAddressesView(View):
permission_required = 'ipam.change_prefix'
model = Prefix
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
default_return_url = 'ipam:prefix_list'
def get(self, request, pk):
class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
model = Prefix
template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_prefix'
form = forms.PrefixImportForm
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
filter = filters.PrefixFilter
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
filter = filters.PrefixFilter
default_return_url = 'ipam:prefix_list'
def prefix_ipaddresses(request, pk):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk) prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
# Find all IPAddresses belonging to this Prefix # Find all IPAddresses belonging to this Prefix
ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\ ipaddresses = IPAddress.objects.filter(
.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for') 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) ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
ip_table = tables.IPAddressTable(ipaddresses) ip_table = tables.IPAddressTable(ipaddresses)
@@ -525,9 +531,52 @@ def prefix_ipaddresses(request, pk):
'prefix': prefix, 'prefix': prefix,
'ip_table': ip_table, 'ip_table': ip_table,
'permissions': permissions, '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'
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
template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list'
class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_prefix'
model_form = forms.PrefixCSVForm
table = tables.PrefixTable
default_return_url = 'ipam:prefix_list'
class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix'
cls = Prefix
filter = filters.PrefixFilter
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
filter = filters.PrefixFilter
default_return_url = 'ipam:prefix_list'
# #
# IP addresses # IP addresses
# #
@@ -540,24 +589,39 @@ class IPAddressListView(ObjectListView):
template_name = 'ipam/ipaddress_list.html' template_name = 'ipam/ipaddress_list.html'
def ipaddress(request, pk): class IPAddressView(View):
def get(self, request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk) ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
# Parent prefixes table # Parent prefixes table
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\ parent_prefixes = Prefix.objects.filter(
.select_related('site', 'role') 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 = tables.PrefixBriefTable(list(parent_prefixes))
parent_prefixes_table.exclude = ('vrf',) parent_prefixes_table.exclude = ('vrf',)
# Duplicate IPs table # Duplicate IPs table
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\ duplicate_ips = IPAddress.objects.filter(
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside') 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)) duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
# Related IP table # Related IP table
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\ related_ips = IPAddress.objects.select_related(
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)) '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)) related_ips_table = tables.IPAddressBriefTable(list(related_ips))
return render(request, 'ipam/ipaddress.html', { return render(request, 'ipam/ipaddress.html', {
@@ -568,21 +632,25 @@ def ipaddress(request, pk):
}) })
class IPAddressEditView(PermissionRequiredMixin, ObjectEditView): class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_ipaddress' permission_required = 'ipam.add_ipaddress'
model = IPAddress model = IPAddress
form_class = forms.IPAddressForm form_class = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html' template_name = 'ipam/ipaddress_edit.html'
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressEditView(IPAddressCreateView):
permission_required = 'ipam.change_ipaddress'
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress' permission_required = 'ipam.delete_ipaddress'
model = IPAddress model = IPAddress
default_return_url = 'ipam:ipaddress_list' default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView): class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
permission_required = 'ipam.add_ipaddress' permission_required = 'ipam.add_ipaddress'
pattern_form = forms.IPAddressPatternForm pattern_form = forms.IPAddressPatternForm
model_form = forms.IPAddressBulkAddForm model_form = forms.IPAddressBulkAddForm
@@ -593,24 +661,10 @@ class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_ipaddress' permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressImportForm model_form = forms.IPAddressCSVForm
table = tables.IPAddressTable table = tables.IPAddressTable
template_name = 'ipam/ipaddress_import.html'
default_return_url = 'ipam:ipaddress_list' 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): class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress' permission_required = 'ipam.change_ipaddress'
@@ -640,8 +694,8 @@ class VLANGroupListView(ObjectListView):
template_name = 'ipam/vlangroup_list.html' template_name = 'ipam/vlangroup_list.html'
class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView): class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlangroup' permission_required = 'ipam.add_vlangroup'
model = VLANGroup model = VLANGroup
form_class = forms.VLANGroupForm form_class = forms.VLANGroupForm
@@ -649,6 +703,10 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('ipam:vlangroup_list') return reverse('ipam:vlangroup_list')
class VLANGroupEditView(VLANGroupCreateView):
permission_required = 'ipam.change_vlangroup'
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup' permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup cls = VLANGroup
@@ -668,9 +726,13 @@ class VLANListView(ObjectListView):
template_name = 'ipam/vlan_list.html' 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) def get(self, request, pk):
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') prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
prefix_table = tables.PrefixBriefTable(list(prefixes)) prefix_table = tables.PrefixBriefTable(list(prefixes))
prefix_table.exclude = ('vlan',) prefix_table.exclude = ('vlan',)
@@ -681,14 +743,18 @@ def vlan(request, pk):
}) })
class VLANEditView(PermissionRequiredMixin, ObjectEditView): class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_vlan' permission_required = 'ipam.add_vlan'
model = VLAN model = VLAN
form_class = forms.VLANForm form_class = forms.VLANForm
template_name = 'ipam/vlan_edit.html' template_name = 'ipam/vlan_edit.html'
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
class VLANEditView(VLANCreateView):
permission_required = 'ipam.change_vlan'
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView): class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan' permission_required = 'ipam.delete_vlan'
model = VLAN model = VLAN
@@ -697,9 +763,8 @@ class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlan' permission_required = 'ipam.add_vlan'
form = forms.VLANImportForm model_form = forms.VLANCSVForm
table = tables.VLANTable table = tables.VLANTable
template_name = 'ipam/vlan_import.html'
default_return_url = 'ipam:vlan_list' default_return_url = 'ipam:vlan_list'
@@ -723,8 +788,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Services # Services
# #
class ServiceEditView(PermissionRequiredMixin, ObjectEditView): class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_service' permission_required = 'ipam.add_service'
model = Service model = Service
form_class = forms.ServiceForm form_class = forms.ServiceForm
template_name = 'ipam/service_edit.html' template_name = 'ipam/service_edit.html'
@@ -738,6 +803,10 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
return obj.device.get_absolute_url() return obj.device.get_absolute_url()
class ServiceEditView(ServiceCreateView):
permission_required = 'ipam.change_service'
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service' permission_required = 'ipam.delete_service'
model = Service model = Service

View File

@@ -58,6 +58,11 @@ CORS_ORIGIN_REGEX_WHITELIST = [
# r'^(https?://)?(\w+\.)?example\.com$', # 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 settings
EMAIL = { EMAIL = {
'SERVER': 'localhost', 'SERVER': 'localhost',
@@ -72,6 +77,10 @@ EMAIL = {
# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. # (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
ENFORCE_GLOBAL_UNIQUE = False 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 # Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
# are permitted to access most data in NetBox (excluding secrets) but not make any changes. # are permitted to access most data in NetBox (excluding secrets) but not make any changes.
LOGIN_REQUIRED = False LOGIN_REQUIRED = False
@@ -79,6 +88,11 @@ LOGIN_REQUIRED = False
# Setting this to True will display a "maintenance mode" banner at the top of every page. # Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = False MAINTENANCE_MODE = False
# 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). # Credentials that NetBox will use to access live devices (future use).
NETBOX_USERNAME = '' NETBOX_USERNAME = ''
NETBOX_PASSWORD = '' NETBOX_PASSWORD = ''

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django import forms from django import forms
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin

View File

@@ -13,9 +13,9 @@ except ImportError:
) )
VERSION = '2.0.2' VERSION = '2.0.8'
# Import local configuration # Import required configuration parameters
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
try: try:
@@ -25,32 +25,35 @@ for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
"Mandatory setting {} is missing from configuration.py.".format(setting) "Mandatory setting {} is missing from configuration.py.".format(setting)
) )
# Default configurations # Import optional configuration parameters
ADMINS = getattr(configuration, 'ADMINS', []) ADMINS = getattr(configuration, 'ADMINS', [])
DEBUG = getattr(configuration, 'DEBUG', False) BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
EMAIL = getattr(configuration, 'EMAIL', {}) BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
BASE_PATH = getattr(configuration, 'BASE_PATH', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH: if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only 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) MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '') NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '')
NETBOX_PASSWORD = getattr(configuration, 'NETBOX_PASSWORD', '') 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') 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') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False) SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# Attempt to import LDAP configuration if it has been defined # Attempt to import LDAP configuration if it has been defined
@@ -112,6 +115,7 @@ INSTALLED_APPS = (
'django.contrib.humanize', 'django.contrib.humanize',
'corsheaders', 'corsheaders',
'debug_toolbar', 'debug_toolbar',
'django_filters',
'django_tables2', 'django_tables2',
'mptt', 'mptt',
'rest_framework', 'rest_framework',
@@ -180,8 +184,8 @@ STATICFILES_DIRS = (
) )
# Media # Media
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, '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.) # 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 DATA_UPLOAD_MAX_NUMBER_FIELDS = None
@@ -207,7 +211,7 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ( 'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.DjangoFilterBackend', 'rest_framework.filters.DjangoFilterBackend',
), ),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'utilities.api.OptionalLimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'utilities.api.TokenPermissions', 'utilities.api.TokenPermissions',
), ),

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework_swagger.views import get_swagger_view from rest_framework_swagger.views import get_swagger_view
from django.conf import settings from django.conf import settings
@@ -5,8 +7,8 @@ from django.conf.urls import include, url
from django.contrib import admin from django.contrib import admin
from django.views.static import serve from django.views.static import serve
from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500 from netbox.views import APIRootView, handle_500, HomeView, SearchView, trigger_500
from users.views import login, logout from users.views import LoginView, LogoutView
handler500 = handle_500 handler500 = handle_500
@@ -15,12 +17,12 @@ swagger_view = get_swagger_view(title='NetBox API')
_patterns = [ _patterns = [
# Base views # Base views
url(r'^$', home, name='home'), url(r'^$', HomeView.as_view(), name='home'),
url(r'^search/$', SearchView.as_view(), name='search'), url(r'^search/$', SearchView.as_view(), name='search'),
# Login/logout # Login/logout
url(r'^login/$', login, name='login'), url(r'^login/$', LoginView.as_view(), name='login'),
url(r'^logout/$', logout, name='logout'), url(r'^logout/$', LogoutView.as_view(), name='logout'),
# Apps # Apps
url(r'^circuits/', include('circuits.urls')), url(r'^circuits/', include('circuits.urls')),

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from collections import OrderedDict
import sys import sys
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -27,94 +29,97 @@ from .forms import SearchForm
SEARCH_MAX_RESULTS = 15 SEARCH_MAX_RESULTS = 15
SEARCH_TYPES = { SEARCH_TYPES = OrderedDict((
# Circuits # Circuits
'provider': { ('provider', {
'queryset': Provider.objects.all(), 'queryset': Provider.objects.all(),
'filter': ProviderFilter, 'filter': ProviderFilter,
'table': ProviderSearchTable, 'table': ProviderSearchTable,
'url': 'circuits:provider_list', 'url': 'circuits:provider_list',
}, }),
'circuit': { ('circuit', {
'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'), 'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
'filter': CircuitFilter, 'filter': CircuitFilter,
'table': CircuitSearchTable, 'table': CircuitSearchTable,
'url': 'circuits:circuit_list', 'url': 'circuits:circuit_list',
}, }),
# DCIM # DCIM
'site': { ('site', {
'queryset': Site.objects.select_related('region', 'tenant'), 'queryset': Site.objects.select_related('region', 'tenant'),
'filter': SiteFilter, 'filter': SiteFilter,
'table': SiteSearchTable, 'table': SiteSearchTable,
'url': 'dcim:site_list', 'url': 'dcim:site_list',
}, }),
'rack': { ('rack', {
'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'), 'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
'filter': RackFilter, 'filter': RackFilter,
'table': RackSearchTable, 'table': RackSearchTable,
'url': 'dcim:rack_list', 'url': 'dcim:rack_list',
}, }),
'devicetype': { ('devicetype', {
'queryset': DeviceType.objects.select_related('manufacturer'), 'queryset': DeviceType.objects.select_related('manufacturer'),
'filter': DeviceTypeFilter, 'filter': DeviceTypeFilter,
'table': DeviceTypeSearchTable, 'table': DeviceTypeSearchTable,
'url': 'dcim:devicetype_list', 'url': 'dcim:devicetype_list',
}, }),
'device': { ('device', {
'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'), 'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'),
'filter': DeviceFilter, 'filter': DeviceFilter,
'table': DeviceSearchTable, 'table': DeviceSearchTable,
'url': 'dcim:device_list', 'url': 'dcim:device_list',
}, }),
# IPAM # IPAM
'vrf': { ('vrf', {
'queryset': VRF.objects.select_related('tenant'), 'queryset': VRF.objects.select_related('tenant'),
'filter': VRFFilter, 'filter': VRFFilter,
'table': VRFSearchTable, 'table': VRFSearchTable,
'url': 'ipam:vrf_list', 'url': 'ipam:vrf_list',
}, }),
'aggregate': { ('aggregate', {
'queryset': Aggregate.objects.select_related('rir'), 'queryset': Aggregate.objects.select_related('rir'),
'filter': AggregateFilter, 'filter': AggregateFilter,
'table': AggregateSearchTable, 'table': AggregateSearchTable,
'url': 'ipam:aggregate_list', 'url': 'ipam:aggregate_list',
}, }),
'prefix': { ('prefix', {
'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filter': PrefixFilter, 'filter': PrefixFilter,
'table': PrefixSearchTable, 'table': PrefixSearchTable,
'url': 'ipam:prefix_list', 'url': 'ipam:prefix_list',
}, }),
'ipaddress': { ('ipaddress', {
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
'filter': IPAddressFilter, 'filter': IPAddressFilter,
'table': IPAddressSearchTable, 'table': IPAddressSearchTable,
'url': 'ipam:ipaddress_list', 'url': 'ipam:ipaddress_list',
}, }),
'vlan': { ('vlan', {
'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'), 'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
'filter': VLANFilter, 'filter': VLANFilter,
'table': VLANSearchTable, 'table': VLANSearchTable,
'url': 'ipam:vlan_list', 'url': 'ipam:vlan_list',
}, }),
# Secrets # Secrets
'secret': { ('secret', {
'queryset': Secret.objects.select_related('role', 'device'), 'queryset': Secret.objects.select_related('role', 'device'),
'filter': SecretFilter, 'filter': SecretFilter,
'table': SecretSearchTable, 'table': SecretSearchTable,
'url': 'secrets:secret_list', 'url': 'secrets:secret_list',
}, }),
# Tenancy # Tenancy
'tenant': { ('tenant', {
'queryset': Tenant.objects.select_related('group'), 'queryset': Tenant.objects.select_related('group'),
'filter': TenantFilter, 'filter': TenantFilter,
'table': TenantSearchTable, 'table': TenantSearchTable,
'url': 'tenancy:tenant_list', 'url': 'tenancy:tenant_list',
}, }),
} ))
def home(request): class HomeView(View):
template_name = 'home.html'
def get(self, request):
stats = { stats = {
@@ -145,7 +150,7 @@ def home(request):
} }
return render(request, 'home.html', { return render(request, self.template_name, {
'search_form': SearchForm(), 'search_form': SearchForm(),
'stats': stats, 'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True), 'topology_maps': TopologyMap.objects.filter(site__isnull=True),
@@ -191,7 +196,7 @@ class SearchView(View):
results.append({ results.append({
'name': queryset.model._meta.verbose_name_plural, 'name': queryset.model._meta.verbose_name_plural,
'table': table, 'table': table,
'url': u'{}?q={}'.format(reverse(url), form.cleaned_data['q']) 'url': '{}?q={}'.format(reverse(url), form.cleaned_data['q'])
}) })
return render(request, 'search.html', { return render(request, 'search.html', {
@@ -205,7 +210,7 @@ class APIRootView(APIView):
exclude_from_schema = True exclude_from_schema = True
def get_view_name(self): def get_view_name(self):
return u"API Root" return "API Root"
def get(self, request, format=None): def get(self, request, format=None):
@@ -234,5 +239,6 @@ def trigger_500(request):
""" """
Hot-wired method of triggering a server error to test reporting 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 " raise Exception(
"person you are.") "Congratulations, you've triggered an exception! Go tell all your friends what an exceptional person you are."
)

View File

@@ -11,6 +11,7 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
application = get_wsgi_application() application = get_wsgi_application()

View File

@@ -74,6 +74,13 @@ footer p {
} }
} }
/* Hide the nav search bar on displays less than 1600px wide */
@media (max-width: 1599px) {
#navbar_search {
display: none;
}
}
/* Forms */ /* Forms */
label { label {
font-weight: normal; font-weight: normal;

View File

@@ -1,6 +1,7 @@
$(document).ready(function() { $(document).ready(function() {
var search_field = $('#id_livesearch'); var search_field = $('#id_livesearch');
var real_field = $('#id_' + search_field.attr('data-field')); var real_field = $('#id_' + search_field.attr('data-field'));
var select_fields = $('#select select');
var search_key = search_field.attr('data-key'); var search_key = search_field.attr('data-key');
var label = search_field.attr('data-label'); var label = search_field.attr('data-label');
if (!label) { if (!label) {
@@ -40,13 +41,22 @@ $(document).ready(function() {
select: function(event, ui) { select: function(event, ui) {
event.preventDefault(); event.preventDefault();
search_field.val(ui.item.label); search_field.val(ui.item.label);
select_fields.val('');
select_fields.attr('disabled', 'disabled');
real_field.empty(); real_field.empty();
real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label)); real_field.append($("<option></option>").attr('value', ui.item.value).text(ui.item.label));
real_field.change(); real_field.change();
// If the field has a parent helper, reset the parent to no selection // Disable parent selection fields
$('select[filter-for="' + real_field.attr('name') + '"]').val(''); // $('select[filter-for="' + real_field.attr('name') + '"]').val('');
}, },
minLength: 4, minLength: 4,
delay: 500 delay: 500
}); });
search_field.change(function() {
if (!search_field.val()) {
select_fields.removeAttr('disabled');
select_fields.val('');
}
});
}); });

View File

@@ -16,7 +16,7 @@ $(document).ready(function() {
// Adding/editing a secret // Adding/editing a secret
$('form').submit(function(event) { $('form').submit(function(event) {
$(this).find('input.requires-session-key').each(function() { $(this).find('.requires-session-key').each(function() {
if (this.value && document.cookie.indexOf('session_key') == -1) { if (this.value && document.cookie.indexOf('session_key') == -1) {
console.log('Field ' + this.value + ' requires a session key'); console.log('Field ' + this.value + ' requires a session key');
$('#privkey_modal').modal('show'); $('#privkey_modal').modal('show');

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.contrib import admin, messages from django.contrib import admin, messages
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
@@ -34,8 +36,8 @@ class UserKeyAdmin(admin.ModelAdmin):
try: try:
my_userkey = UserKey.objects.get(user=request.user) my_userkey = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist: except UserKey.DoesNotExist:
messages.error(request, u"You do not have an active User Key.") messages.error(request, "You do not have an active User Key.")
return redirect('/admin/secrets/userkey/') return redirect('admin:secrets_userkey_changelist')
if 'activate' in request.POST: if 'activate' in request.POST:
form = ActivateUserKeyForm(request.POST) form = ActivateUserKeyForm(request.POST)
@@ -44,9 +46,9 @@ class UserKeyAdmin(admin.ModelAdmin):
master_key = my_userkey.get_master_key(form.cleaned_data['secret_key']) master_key = my_userkey.get_master_key(form.cleaned_data['secret_key'])
for uk in form.cleaned_data['_selected_action']: for uk in form.cleaned_data['_selected_action']:
uk.activate(master_key) uk.activate(master_key)
return redirect('/admin/secrets/userkey/') return redirect('admin:secrets_userkey_changelist')
except ValueError: except ValueError:
messages.error(request, u"Invalid private key provided. Unable to retrieve master key.") messages.error(request, "Invalid private key provided. Unable to retrieve master key.")
else: else:
form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)}) form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers from rest_framework import routers
from . import views from . import views

View File

@@ -1,13 +1,14 @@
from __future__ import unicode_literals
import base64 import base64
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from django.http import HttpResponseBadRequest
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet, ViewSet from rest_framework.viewsets import ModelViewSet, ViewSet
from django.http import HttpResponseBadRequest
from secrets import filters from secrets import filters
from secrets.exceptions import InvalidKey from secrets.exceptions import InvalidKey
from secrets.models import Secret, SecretRole, SessionKey, UserKey from secrets.models import Secret, SecretRole, SessionKey, UserKey

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.contrib import messages from django.contrib import messages
from django.shortcuts import redirect from django.shortcuts import redirect
@@ -14,10 +16,10 @@ def userkey_required():
try: try:
uk = UserKey.objects.get(user=request.user) uk = UserKey.objects.get(user=request.user)
except UserKey.DoesNotExist: except UserKey.DoesNotExist:
messages.warning(request, u"This operation requires an active user key, but you don't have one.") messages.warning(request, "This operation requires an active user key, but you don't have one.")
return redirect('user:userkey') return redirect('user:userkey')
if not uk.is_active(): if not uk.is_active():
messages.warning(request, u"This operation is not available. Your user key has not been activated.") messages.warning(request, "This operation is not available. Your user key has not been activated.")
return redirect('user:userkey') return redirect('user:userkey')
return view(request, *args, **kwargs) return view(request, *args, **kwargs)
return wrapped_view return wrapped_view

View File

@@ -1,3 +1,6 @@
from __future__ import unicode_literals
class InvalidKey(Exception): class InvalidKey(Exception):
""" """
Raised when a provided key is invalid. Raised when a provided key is invalid.

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
import django_filters import django_filters
from django.db.models import Q from django.db.models import Q
@@ -30,7 +32,7 @@ class SecretFilter(django_filters.FilterSet):
label='Device (ID)', label='Device (ID)',
) )
device = django_filters.ModelMultipleChoiceFilter( device = django_filters.ModelMultipleChoiceFilter(
name='device', name='device__name',
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',
label='Device (name)', label='Device (name)',

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from Crypto.Cipher import PKCS1_OAEP from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
@@ -5,8 +7,7 @@ from django import forms
from django.db.models import Count from django.db.models import Count
from dcim.models import Device from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField from utilities.forms import BootstrapMixin, BulkEditForm, FilterChoiceField, FlexibleModelChoiceField, SlugField
from .models import Secret, SecretRole, UserKey from .models import Secret, SecretRole, UserKey
@@ -64,27 +65,40 @@ class SecretForm(BootstrapMixin, forms.ModelForm):
}) })
class SecretFromCSVForm(forms.ModelForm): class SecretCSVForm(forms.ModelForm):
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', device = FlexibleModelChoiceField(
error_messages={'invalid_choice': 'Device not found.'}) queryset=Device.objects.all(),
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), to_field_name='name', to_field_name='name',
error_messages={'invalid_choice': 'Invalid secret role.'}) help_text='Device name or ID',
plaintext = forms.CharField() error_messages={
'invalid_choice': 'Device not found.',
}
)
role = forms.ModelChoiceField(
queryset=SecretRole.objects.all(),
to_field_name='name',
help_text='Name of assigned role',
error_messages={
'invalid_choice': 'Invalid secret role.',
}
)
plaintext = forms.CharField(
help_text='Plaintext secret data'
)
class Meta: class Meta:
model = Secret model = Secret
fields = ['device', 'role', 'name', 'plaintext'] fields = ['device', 'role', 'name', 'plaintext']
help_texts = {
'name': 'Name or username',
}
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
s = super(SecretFromCSVForm, self).save(*args, **kwargs) s = super(SecretCSVForm, self).save(*args, **kwargs)
s.plaintext = str(self.cleaned_data['plaintext']) s.plaintext = str(self.cleaned_data['plaintext'])
return s return s
class SecretImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-session-key'}))
class SecretBulkEditForm(BootstrapMixin, BulkEditForm): class SecretBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False)

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.contrib.auth.hashers import PBKDF2PasswordHasher from django.contrib.auth.hashers import PBKDF2PasswordHasher

View File

@@ -0,0 +1,20 @@
# -*- 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
class Migration(migrations.Migration):
dependencies = [
('secrets', '0002_userkey_add_session_key'),
]
operations = [
migrations.AlterField(
model_name='userkey',
name='public_key',
field=models.TextField(verbose_name='RSA public key'),
),
]

View File

@@ -1,4 +1,6 @@
from __future__ import unicode_literals
import os import os
from Crypto.Cipher import AES, PKCS1_OAEP, XOR from Crypto.Cipher import AES, PKCS1_OAEP, XOR
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
@@ -12,7 +14,6 @@ from django.utils.encoding import force_bytes, python_2_unicode_compatible
from dcim.models import Device from dcim.models import Device
from utilities.models import CreatedUpdatedModel from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidKey from .exceptions import InvalidKey
from .hashers import SecretValidationHasher from .hashers import SecretValidationHasher
@@ -290,6 +291,7 @@ class Secret(CreatedUpdatedModel):
hash = models.CharField(max_length=128, editable=False) hash = models.CharField(max_length=128, editable=False)
plaintext = None plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext']
class Meta: class Meta:
ordering = ['device', 'role', 'name'] ordering = ['device', 'role', 'name']
@@ -301,8 +303,8 @@ class Secret(CreatedUpdatedModel):
def __str__(self): def __str__(self):
if self.role and self.device: if self.role and self.device:
return u'{} for {}'.format(self.role, self.device) return '{} for {}'.format(self.role, self.device)
return u'Secret' return 'Secret'
def get_absolute_url(self): def get_absolute_url(self):
return reverse('secrets:secret', args=[self.pk]) return reverse('secrets:secret', args=[self.pk])

View File

@@ -1,5 +1,6 @@
from __future__ import unicode_literals
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, SearchTable, ToggleColumn from utilities.tables import BaseTable, SearchTable, ToggleColumn
@@ -22,8 +23,9 @@ class SecretRoleTable(BaseTable):
name = tables.LinkColumn(verbose_name='Name') name = tables.LinkColumn(verbose_name='Name')
secret_count = tables.Column(verbose_name='Secrets') secret_count = tables.Column(verbose_name='Secrets')
slug = tables.Column(verbose_name='Slug') slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, actions = tables.TemplateColumn(
verbose_name='') template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = SecretRole model = SecretRole

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django import template from django import template

View File

@@ -1,4 +1,6 @@
from __future__ import unicode_literals
import base64 import base64
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from django.conf import settings from django.conf import settings

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.conf.urls import url from django.conf.urls import url
from . import views from . import views
@@ -8,16 +10,16 @@ urlpatterns = [
# Secret roles # Secret roles
url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'), url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'),
url(r'^secret-roles/add/$', views.SecretRoleEditView.as_view(), name='secretrole_add'), url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'), url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
# Secrets # Secrets
url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'),
url(r'^secrets/import/$', views.secret_import, name='secret_import'), url(r'^secrets/import/$', views.SecretBulkImportView.as_view(), name='secret_import'),
url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
url(r'^secrets/(?P<pk>\d+)/$', views.secret, name='secret'), url(r'^secrets/(?P<pk>\d+)/$', views.SecretView.as_view(), name='secret'),
url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'), url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'),
url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'), url(r'^secrets/(?P<pk>\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'),

View File

@@ -1,3 +1,4 @@
from __future__ import unicode_literals
import base64 import base64
from django.contrib import messages from django.contrib import messages
@@ -8,10 +9,12 @@ from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import View
from dcim.models import Device from dcim.models import Device
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables from . import filters, forms, tables
from .decorators import userkey_required from .decorators import userkey_required
from .models import SecretRole, Secret, SessionKey from .models import SecretRole, Secret, SessionKey
@@ -37,8 +40,8 @@ class SecretRoleListView(ObjectListView):
template_name = 'secrets/secretrole_list.html' template_name = 'secrets/secretrole_list.html'
class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView): class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.change_secretrole' permission_required = 'secrets.add_secretrole'
model = SecretRole model = SecretRole
form_class = forms.SecretRoleForm form_class = forms.SecretRoleForm
@@ -46,6 +49,10 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
return reverse('secrets:secretrole_list') return reverse('secrets:secretrole_list')
class SecretRoleEditView(SecretRoleCreateView):
permission_required = 'secrets.change_secretrole'
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secretrole' permission_required = 'secrets.delete_secretrole'
cls = SecretRole cls = SecretRole
@@ -65,8 +72,10 @@ class SecretListView(ObjectListView):
template_name = 'secrets/secret_list.html' template_name = 'secrets/secret_list.html'
@login_required @method_decorator(login_required, name='dispatch')
def secret(request, pk): class SecretView(View):
def get(self, request, pk):
secret = get_object_or_404(Secret, pk=pk) secret = get_object_or_404(Secret, pk=pk)
@@ -107,7 +116,7 @@ def secret_add(request, pk):
secret.plaintext = str(form.cleaned_data['plaintext']) secret.plaintext = str(form.cleaned_data['plaintext'])
secret.encrypt(master_key) secret.encrypt(master_key)
secret.save() secret.save()
messages.success(request, u"Added new secret: {}.".format(secret)) messages.success(request, "Added new secret: {}.".format(secret))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:device_addsecret', pk=device.pk) return redirect('dcim:device_addsecret', pk=device.pk)
else: else:
@@ -151,7 +160,7 @@ def secret_edit(request, pk):
secret.plaintext = str(form.cleaned_data['plaintext']) secret.plaintext = str(form.cleaned_data['plaintext'])
secret.encrypt(master_key) secret.encrypt(master_key)
secret.save() secret.save()
messages.success(request, u"Modified secret {}.".format(secret)) messages.success(request, "Modified secret {}.".format(secret))
return redirect('secrets:secret', pk=secret.pk) return redirect('secrets:secret', pk=secret.pk)
else: else:
form.add_error(None, "Invalid session key. Unable to encrypt secret data.") form.add_error(None, "Invalid session key. Unable to encrypt secret data.")
@@ -163,7 +172,7 @@ def secret_edit(request, pk):
# If no new plaintext was specified, a session key is not needed. # If no new plaintext was specified, a session key is not needed.
else: else:
secret = form.save() secret = form.save()
messages.success(request, u"Modified secret {}.".format(secret)) messages.success(request, "Modified secret {}.".format(secret))
return redirect('secrets:secret', pk=secret.pk) return redirect('secrets:secret', pk=secret.pk)
else: else:
@@ -182,57 +191,49 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
default_return_url = 'secrets:secret_list' default_return_url = 'secrets:secret_list'
@permission_required('secrets.add_secret') class SecretBulkImportView(BulkImportView):
@userkey_required() permission_required = 'ipam.add_vlan'
def secret_import(request): model_form = forms.SecretCSVForm
table = tables.SecretTable
default_return_url = 'secrets:secret_list'
session_key = request.COOKIES.get('session_key', None)
if request.method == 'POST':
form = forms.SecretImportForm(request.POST)
if session_key is None:
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
if form.is_valid():
new_secrets = []
session_key = base64.b64decode(session_key)
master_key = None master_key = None
def _save_obj(self, obj_form):
"""
Encrypt each object before saving it to the database.
"""
obj = obj_form.save(commit=False)
obj.encrypt(self.master_key)
obj.save()
return obj
def post(self, request):
# Grab the session key from cookies.
session_key = request.COOKIES.get('session_key')
if session_key:
# Attempt to derive the master key using the provided session key.
try: try:
sk = SessionKey.objects.get(userkey__user=request.user) sk = SessionKey.objects.get(userkey__user=request.user)
master_key = sk.get_master_key(session_key) self.master_key = sk.get_master_key(base64.b64decode(session_key))
except SessionKey.DoesNotExist: except SessionKey.DoesNotExist:
form.add_error(None, "No session key found for this user.") messages.error(request, "No session key found for this user.")
if master_key is None: if self.master_key is not None:
form.add_error(None, "Invalid private key! Unable to encrypt secret data.") return super(SecretBulkImportView, self).post(request)
else: else:
try: messages.error(request, "Invalid private key! Unable to encrypt secret data.")
with transaction.atomic():
for secret in form.cleaned_data['csv']:
secret.encrypt(master_key)
secret.save()
new_secrets.append(secret)
table = tables.SecretTable(new_secrets)
messages.success(request, u"Imported {} new secrets.".format(len(new_secrets)))
return render(request, 'import_success.html', {
'table': table,
'return_url': 'secrets:secret_list',
})
except IntegrityError as e:
form.add_error('csv', "Record {}: {}".format(len(new_secrets) + 1, e.__cause__))
else: else:
form = forms.SecretImportForm() messages.error(request, "No session key was provided with the request. Unable to encrypt secret data.")
return render(request, 'secrets/secret_import.html', { return render(request, self.template_name, {
'form': form, 'form': self._import_form(request.POST),
'return_url': 'secrets:secret_list', 'fields': self.model_form().fields,
'obj_type': self.model_form._meta.model._meta.verbose_name,
'return_url': self.default_return_url,
}) })

View File

@@ -1,13 +1,15 @@
{% load static from staticfiles %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>Server Error</title> <title>Server Error</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css"> <link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css"> <link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
</head> </head>
<body> <body>
<div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-md-4 col-md-offset-4"> <div class="col-md-4 col-md-offset-4">
<div class="panel panel-danger" style="margin-top: 200px"> <div class="panel panel-danger" style="margin-top: 200px">
@@ -32,6 +34,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</body> </body>
</html> </html>

View File

@@ -246,8 +246,8 @@
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<li class="dropdown"> <li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" title="{{ request.user }}" role="button" aria-haspopup="true" aria-expanded="false">
{{ request.user }} <span class="caret"></span> {{ request.user|truncatechars:"30" }} <span class="caret"></span>
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="{% url 'user:profile' %}"><i class="fa fa-user" aria-hidden="true"></i> Profile</a></li> <li><a href="{% url 'user:profile' %}"><i class="fa fa-user" aria-hidden="true"></i> Profile</a></li>
@@ -262,7 +262,7 @@
<li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in" aria-hidden="true"></i> Log in</a></li> <li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in" aria-hidden="true"></i> Log in</a></li>
{% endif %} {% endif %}
</ul> </ul>
<form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" role="search"> <form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" id="navbar_search" role="search">
<div class="input-group"> <div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search"> <input type="text" name="q" class="form-control" placeholder="Search">
<span class="input-group-btn"> <span class="input-group-btn">

View File

@@ -1,57 +0,0 @@
{% extends 'utilities/obj_import.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Circuit Import{% endblock %}
{% block instructions %}
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Circuit ID</td>
<td>Alphanumeric circuit identifier</td>
<td>IC-603122</td>
</tr>
<tr>
<td>Provider</td>
<td>Name of circuit provider</td>
<td>TeliaSonera</td>
</tr>
<tr>
<td>Type</td>
<td>Circuit type</td>
<td>Transit</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Strickland Propane</td>
</tr>
<tr>
<td>Install Date</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Commit rate</td>
<td>Commited rate in Kbps (optional)</td>
<td>2000</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Primary for voice</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000,Primary for voice</pre>
{% endblock %}

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