Compare commits

...

172 Commits

Author SHA1 Message Date
Jeremy Stretch
ad95b86fdd Merge pull request #1201 from digitalocean/develop
Release v2.0.3
2017-05-18 14:37:19 -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
Jeremy Stretch
43e1e0dbc8 Merge pull request #1181 from digitalocean/develop
Release v2.0.2
2017-05-15 13:23:33 -04:00
Jeremy Stretch
a1c12cfd77 Release v2.0.2 2017-05-15 13:19:18 -04:00
Jeremy Stretch
aa6ca21a34 PEP8 fix 2017-05-15 13:18:49 -04:00
Jeremy Stretch
a49521d683 #1177: Render planned connections as dashed lines on topology maps 2017-05-15 13:11:20 -04:00
Jeremy Stretch
3be6e5b015 Closes #1179: Adjust topology map text color based on node background 2017-05-15 12:56:16 -04:00
Jeremy Stretch
ca1725b98c Fixes #1178: Fix API representation of connected interface's form factor 2017-05-15 11:03:11 -04:00
Jeremy Stretch
d11dfe2ced Closes #1137: Allow filtering devices list by rack 2017-05-12 22:41:27 -04:00
Jeremy Stretch
ab30ba1e1b Fixed dynamic selection of device type filter on devices list 2017-05-12 22:20:21 -04:00
Jeremy Stretch
7f23cb9bf5 Closes #1122: Include NAT inside IPs in IP address list 2017-05-12 22:11:20 -04:00
Jeremy Stretch
c9d3cf301e Fixes #1173: Tweak interface manager to fall back to naive ordering 2017-05-12 16:10:18 -04:00
Jeremy Stretch
67282882fa Fixed RelatedObjectDoesNotExist error when trying to create a new device 2017-05-12 15:55:18 -04:00
Jeremy Stretch
73bf4f45c3 Adapted model get_display_name() to better handle unsaved instances 2017-05-12 15:31:34 -04:00
Jeremy Stretch
66ae62fb91 Closes #1172: Linkify racks in side-by-side elevations view 2017-05-12 14:19:37 -04:00
Jeremy Stretch
8bae804508 Closes #1170: Include A and Z sites for circuits in global search results 2017-05-12 12:12:47 -04:00
Jeremy Stretch
d87acc97c3 Fixes #1171: Allow removing site assignment when bulk editing VLANs 2017-05-12 12:06:37 -04:00
Jeremy Stretch
f9b2c59974 Moved tenancy to separate panel on bulk IP creation form 2017-05-12 12:04:06 -04:00
Jeremy Stretch
a870a3b918 Fixes #1166: Re-implemented bulk IP address creation 2017-05-12 12:00:26 -04:00
Jeremy Stretch
008ed34553 Fixes #1168: Total count of obejcts missing from list view paginator 2017-05-11 23:30:23 -04:00
Jeremy Stretch
e239045688 PEP8 fixes 2017-05-11 17:54:43 -04:00
Jeremy Stretch
ed80bfaf02 Fixed selector initializations for TenancyForms 2017-05-11 17:52:23 -04:00
Jeremy Stretch
473b35f9a3 Added tenant_group/tenant form section to all objects with tenancy 2017-05-11 17:35:20 -04:00
Jeremy Stretch
45bb7eec0b Corrected queryset filter when parent_field is None 2017-05-11 17:20:50 -04:00
Jeremy Stretch
58bb029666 Closes #1167: Introduced ChainedModelChoiceFields 2017-05-11 16:30:16 -04:00
Jeremy Stretch
0f97478b55 Fixes #1161: Fix "add another" behavior when creating an API token 2017-05-10 22:22:49 -04:00
Jeremy Stretch
9efa70a551 Fixes #1159: Only superusers can see "edit IP" buttons on the device interfaces list 2017-05-10 16:02:50 -04:00
Jeremy Stretch
ed65721085 Fixes #1160: Linkify secrets and tenants in global search results 2017-05-10 13:16:33 -04:00
Jeremy Stretch
83688fceb7 Fixes #1158: Exception thrown when creating a device component with an invalid name 2017-05-10 11:23:54 -04:00
Jeremy Stretch
088f75ba0c Added client_max_body_size to nginx config; removed statement disabling access logging 2017-05-10 11:11:03 -04:00
Jeremy Stretch
188cfa08a9 Post-release version bump 2017-05-09 22:48:14 -04:00
Jeremy Stretch
f731900e2f Merge pull request #1154 from digitalocean/develop
Release v2.0.1
2017-05-09 22:47:52 -04:00
Jeremy Stretch
b89bd24bed Release v2.0.1 2017-05-09 22:41:37 -04:00
Jeremy Stretch
effda88b51 Fixes #1153: UnicodeEncodeError when searching for non-ASCII characters on Python 2 2017-05-09 22:40:46 -04:00
Jeremy Stretch
3844f70a4d Fixes #1152: Unable to edit user keys 2017-05-09 17:53:37 -04:00
Jeremy Stretch
8e333757f9 Fixes #1150: Error when uploading image attachments with Unicode names under Python 2 2017-05-09 17:12:45 -04:00
Jeremy Stretch
0fb12bcc9c Fixes #1151: name 'escape' is not defined 2017-05-09 17:06:17 -04:00
Jeremy Stretch
44d78ef92a Fixes #1149: Port list does not populate when creating a console or power connection 2017-05-09 17:01:43 -04:00
Jeremy Stretch
ebb6729a26 Post-release version bump 2017-05-09 15:13:40 -04:00
Jeremy Stretch
b1bcaa33e7 Merge pull request #1148 from digitalocean/develop
Release v2.0.0
2017-05-09 15:09:28 -04:00
Jeremy Stretch
a35f8bddde PEP8 fix 2017-05-09 14:44:32 -04:00
Jeremy Stretch
8fbe7ba742 Release v2.0.0! 2017-05-09 14:29:11 -04:00
Jeremy Stretch
f039b0b6e9 Closes #960: Added form factor for Juniper VCP interfaces 2017-05-09 12:00:49 -04:00
Jeremy Stretch
9ad9ef7957 Fixed incorrect API URL in IPAddressForm 2017-05-09 11:11:30 -04:00
Jeremy Stretch
5c7db04465 Closes #853: Add 'status' field to device bulk import form 2017-05-09 10:25:30 -04:00
Jeremy Stretch
838105fb65 Merging v2.0 development into mainline (#1145)
Merging v2.0 development into mainline
2017-05-08 15:06:57 -04:00
Jeremy Stretch
5ca87c0f20 Merge branch 'develop' into v2-develop 2017-05-08 15:02:06 -04:00
Jeremy Stretch
af4edff370 Related to #1144: Allow multiple status selections when filtering device list 2017-05-08 14:56:25 -04:00
Jeremy Stretch
f40c048475 Fixes #1144: Allow multiple status selections for Prefix, IP address, and VLAN filters 2017-05-08 14:32:29 -04:00
Jeremy Stretch
77247cccbe Closes #154: Expand device status field options 2017-05-08 13:55:19 -04:00
Jeremy Stretch
fcfcd77bfd Moved LAG members list to the description column 2017-05-05 15:37:42 -04:00
Jeremy Stretch
b3667befb4 Removed reduntant title block 2017-05-05 15:24:58 -04:00
Jeremy Stretch
a6cb0e0a96 Updated console/power connection icons 2017-05-03 17:24:57 -04:00
Jeremy Stretch
c047f943de Fixes #403: Record console/power/interface connects and disconnects as user actions 2017-05-03 17:12:34 -04:00
Jeremy Stretch
79089cc47e Introduced an object import template 2017-05-03 15:41:36 -04:00
Jeremy Stretch
3c631902e1 Closes #1100: Add a "view all" link to completed bulk import views is_pool for prefixes 2017-05-03 15:27:26 -04:00
Jeremy Stretch
379c24a012 Fixed typo in template 2017-05-03 14:32:27 -04:00
Brian Ellwood
4035b87693 Allow responsive tables (#1124)
* Make tables responsive #1115

Resolves #1115
2017-05-03 14:30:05 -04:00
Jeremy Stretch
11d1a8c3cf Merge pull request #1128 from digitalocean/readme-branches
Fix misleading build matrix
2017-05-03 14:21:07 -04:00
Jeremy Stretch
7eb9c8265c Fixes #1132: Prompt user to unlock session key when importing secrets 2017-05-03 11:47:28 -04:00
Matt Layher
572beb2311 Fix misleading build matrix
At one point, I had intended to have a matrix of build badges for each different branch and Python version combination.  It seems this is not possible with Travis.

This change replaces "python 2.7" with "status" and clarifies that both Python 2.7 and 3.5 are tested, but Python 3.5 is recommended.
2017-05-02 20:39:43 -04:00
Jeremy Stretch
d861d8bfb8 Fixes #1118: Allow designating an IP as primary for a device while editing the IP 2017-05-02 16:46:23 -04:00
Jeremy Stretch
6791ff6192 Fixes #1125: Include MAC addresses on a device's interface list 2017-05-02 15:01:27 -04:00
Jeremy Stretch
9d9de6b2a3 Fixes #1126: Fix error when editing a user key via admin UI 2017-05-02 14:50:36 -04:00
Jeremy Stretch
1f7ef15ad1 Fixes #1116: Correct object links on recursive deletion error 2017-05-02 11:43:11 -04:00
Jeremy Stretch
16c582ec7a Enable stale .pyc cleanup in upgrade.sh 2017-05-01 16:53:51 -04:00
Jeremy Stretch
de58d0ecca Fixes #1114: Suppress OSError when attempting to access a delete image attachment 2017-04-28 14:26:17 -04:00
Jeremy Stretch
010f6c7f1a Fixes #1113: Fixes server error when attempting to delete an image attachment 2017-04-28 14:05:02 -04:00
Jeremy Stretch
aea5612c39 Closes #1110: Expand bulk edit forms to include boolean fields (e.g. toggle is_pool for prefixes) 2017-04-28 12:32:27 -04:00
Jeremy Stretch
b8b912bdd5 Post-release version adjustment 2017-04-27 15:42:24 -04:00
Jeremy Stretch
e4ca88726e Release v2.0-beta3 2017-04-27 15:37:15 -04:00
Jeremy Stretch
616f109671 Merge branch 'develop' into v2-develop
Conflicts:
	netbox/ipam/forms.py
2017-04-27 15:29:40 -04:00
Jeremy Stretch
8e0580ff96 Improved upgrade script 2017-04-27 14:42:52 -04:00
Jeremy Stretch
4b2e7620dd Switched user nav menu with search form 2017-04-27 13:27:16 -04:00
Jeremy Stretch
b82f25c503 Merge branch 'writable-custom-fields' into v2-develop 2017-04-27 13:05:44 -04:00
Jeremy Stretch
c174c0cc6d Converted all necessary serializers to CustomFieldModelSerializers 2017-04-27 12:50:43 -04:00
Jeremy Stretch
117da337c7 Corrected tests and improved validation 2017-04-27 12:46:04 -04:00
Jeremy Stretch
01da46f753 Fixes #1107: Corrected exception on creating/deleting image attachments 2017-04-27 11:32:08 -04:00
Jeremy Stretch
d17efce4f5 Fixes #1111: Correct database ordering of SessionKey model 2017-04-27 11:27:34 -04:00
Jeremy Stretch
e7a6d1f532 Fixes #1104: Fix VLAN assignment on prefix import 2017-04-26 13:28:09 -04:00
Jeremy Stretch
f643f2c601 Fixes #1103: Correct handling of validation errors when creating IP addresses in bulk 2017-04-26 13:21:38 -04:00
Jeremy Stretch
480faa6461 Removed deprecated IPAddressAssignForm 2017-04-26 13:03:18 -04:00
Jeremy Stretch
1fa084b6be Fixes #1101: Fix AJAX scripting for device component selection forms 2017-04-26 12:53:14 -04:00
Jeremy Stretch
1c86b00b5c Added custom field API tests 2017-04-25 14:53:18 -04:00
Jeremy Stretch
10823e1c37 Got rudimentary custom field creates/updates working 2017-04-25 13:00:28 -04:00
Jeremy Stretch
f73693206f Merge branch 'develop' into v2-develop
Conflicts:
	netbox/circuits/models.py
	netbox/netbox/settings.py
	upgrade.sh
2017-04-21 15:07:48 -04:00
Jeremy Stretch
861c8b29c0 Post-release version bump 2017-04-21 14:56:36 -04:00
Jeremy Stretch
17873706b7 Merge pull request #1094 from digitalocean/develop
Release v1.9.6
2017-04-21 14:52:53 -04:00
Jeremy Stretch
5037046624 Release v1.9.6 2017-04-21 14:47:31 -04:00
Jeremy Stretch
5c0614d656 #1090: Python3 tweaks for installation on CentOS 2017-04-21 14:37:47 -04:00
Jeremy Stretch
697866d1ba #1090: Tweaked docs for Python3 on Ubuntu 2017-04-21 13:30:18 -04:00
Jeremy Stretch
38d826d152 Fixes #1092: Increase randomness in SECRET_KEY generation tool 2017-04-21 10:32:10 -04:00
Jeremy Stretch
13cc29cd8c Closes #951: Provide a side-by-side view of rack elevations 2017-04-20 13:07:22 -04:00
Jeremy Stretch
401357b8cb Closes #1084: Include custom fields when creating IP addresses in bulk 2017-04-19 14:50:58 -04:00
Jeremy Stretch
599e1bb220 Fixes #1071: Protect assigned circuit termination when an interface is deleted 2017-04-19 13:19:30 -04:00
Jeremy Stretch
864fa17b75 Closes #1008: Moved Docker components into their own repository 2017-04-19 10:58:42 -04:00
Jeremy Stretch
a98c9ed0af Corrected invalid API URL name 2017-04-17 15:52:23 -04:00
Jeremy Stretch
8032aa1ad9 Fixes #1078: Increase default limit for number of objects returned by web form API call 2017-04-17 15:50:00 -04:00
Jeremy Stretch
b01bf6089c Merge branch 'develop' into v2-develop
Conflicts:
	netbox/dcim/forms.py
	netbox/dcim/views.py
	netbox/ipam/forms.py
	netbox/templates/_base.html
	netbox/utilities/views.py
2017-04-13 15:42:50 -04:00
Jeremy Stretch
f9a33bfc14 Fixes #1074: Require ncclient 0.5.3 (Python 3 fix) 2017-04-13 15:34:35 -04:00
Jeremy Stretch
610b412506 #878: Layout tweaks 2017-04-13 15:09:08 -04:00
Jeremy Stretch
09000ad9b3 Closes #1001: Merged IP interface assignment into ipam.IPAddressForm 2017-04-13 14:54:17 -04:00
Jeremy Stretch
f70f0f8d62 Improved handling of return_url for object edit/delete views; removed manual definitions of initial data fields 2017-04-13 13:11:23 -04:00
Jeremy Stretch
d5c3f9e780 #878: Show assigned IP addresses in device interfaces list 2017-04-12 22:02:23 -04:00
Jeremy Stretch
b42dab3eef Differentiate between LAG and virtual interfaces in device interface list 2017-04-12 16:06:36 -04:00
Jeremy Stretch
7cbea49c2d Fixes #1072: Order LAG interfaces naturally on bulk interface edit form 2017-04-12 15:51:14 -04:00
Jeremy Stretch
6dcc5a1169 Merge pull request #1070 from bellwood/patch-1
Python3 fixes for CentOS/RHEL
2017-04-12 15:25:36 -04:00
bellwood
53129125dd Python3 fixes for CentOS/RHEL
1) python3 should be python34
2) python34-pip does does exist, you must install python34-setuptools and then: easy_install-3.4 pip
2017-04-12 09:42:48 -04:00
Jeremy Stretch
2d52b9fb39 Fixes #1059: Allow filtering of interface connections via API 2017-04-10 16:15:36 -04:00
Jeremy Stretch
863cbb785d Merge pull request #1064 from eliezerlp/v2-develop
Pointing Dockerfile to 'v2-beta' branch instead of a particular tag
2017-04-10 10:57:18 -04:00
Jeremy Stretch
ba1a4f06ff Replace tabs with spaces 2017-04-10 10:55:05 -04:00
Jeremy Stretch
cf5be85dad Closes #1061: Escape all messages by default (complements #1062) 2017-04-10 10:54:35 -04:00
Eliezer Paiewonsky
d21b67446f Pointing Dockerfile to 'v2-beta' branch
Was pointing to a particular tag instead.
2017-04-10 10:27:32 -04:00
Jeremy Stretch
3b48a270fc Merge pull request #1062 from asteinhauser/develop
XSS flaw bugfix
2017-04-10 10:14:31 -04:00
Anthony Steinhauser
105e9da866 XSS flaw bugfix 2017-04-10 16:00:22 +02:00
Jeremy Stretch
d3b16ba443 Fixes #1057: Corrected VLAN validation during prefix import 2017-04-07 14:50:08 -04:00
Jeremy Stretch
57fc6a3f50 Merge branch 'develop' into v2-develop
Conflicts:
	netbox/netbox/settings.py
	netbox/netbox/urls.py
	requirements.txt
2017-04-06 17:01:13 -04:00
Jeremy Stretch
abc51fdc5d Post-release version bump 2017-04-06 16:36:42 -04:00
Jeremy Stretch
e0ad2b4555 Merge pull request #1054 from digitalocean/develop
Release v1.9.5
2017-04-06 16:35:15 -04:00
Jeremy Stretch
35a0a658a7 Release v1.9.5 2017-04-06 16:34:00 -04:00
Jeremy Stretch
2c99a8bee4 Closes #1052: Added rack reservation list and bulk delete views 2017-04-06 16:26:48 -04:00
Jeremy Stretch
1dd2bdcb8e Fixes #1047: Correct ordering of numbered subinterfaces 2017-04-06 15:13:20 -04:00
Jeremy Stretch
9f67da00d1 Colored nodes in topology maps 2017-04-06 14:12:30 -04:00
Jeremy Stretch
82d53a8c3d Fixes #1049: Prompt user if missing session key when adding/editing a secret 2017-04-06 13:55:40 -04:00
Jeremy Stretch
f3eee25527 Fixes #1051: Upgraded django-rest-swagger 2017-04-06 11:54:13 -04:00
Jeremy Stretch
ee11775425 Fixes #1051: Upgraded django-rest-swagger 2017-04-06 09:40:09 -04:00
Jeremy Stretch
bcdf9ac5ca Merge pull request #1046 from digitalocean/component-filter-by-name
Fixes #1045
2017-04-06 09:14:41 -04:00
Jeremy Stretch
4accdf77f8 Closes #578: Show topology maps not assigned to a site on the home view 2017-04-05 17:33:39 -04:00
Jeremy Stretch
fc46f70153 Closes #430: Include circuits when rendering topology maps 2017-04-05 17:24:40 -04:00
Zach Moody
e7cf7d58b8 Fixes #1045 2017-04-05 15:29:53 -05:00
Jeremy Stretch
d98e9e1838 Resolved RemovedInDjango20Warning deprecation warnings 2017-04-05 14:40:25 -04:00
Jeremy Stretch
369d3aa62e Rearranged URL namespaces to satisfy deprecation warnings 2017-04-05 14:26:33 -04:00
Jeremy Stretch
d4ac6dbfe4 Fixes #1043: Corrected queryset in WritableDeviceSerializer validation 2017-04-05 13:38:23 -04:00
Jeremy Stretch
91d35905fd Reset version 2017-04-05 12:11:48 -04:00
Jeremy Stretch
78b0072051 Limit <v2.0 installations to Django 1.10 2017-04-05 11:34:04 -04:00
Jeremy Stretch
7766e1f684 Fixes #1037: Fixed error on VLAN import with duplicate VLAN group names 2017-04-05 10:13:19 -04:00
Jeremy Stretch
f89d91783b Merge pull request #1035 from digitalocean/develop
Release v1.9.4-r1
2017-04-04 15:50:28 -04:00
Jeremy Stretch
3ffe36e5ed Merge pull request #1032 from digitalocean/develop
Release v1.9.4
2017-04-04 12:01:58 -04:00
Jeremy Stretch
be393a9d10 Merge pull request #989 from digitalocean/develop
Release v1.9.3
2017-03-23 16:27:06 -04:00
Jeremy Stretch
27eefd8705 Merge pull request #966 from digitalocean/develop
Release v1.9.2
2017-03-14 17:14:19 -04:00
Jeremy Stretch
097e0f38ff Merge pull request #949 from digitalocean/develop
Release v1.9.1
2017-03-08 14:40:16 -05:00
Jeremy Stretch
ce26b566a4 Merge pull request #939 from digitalocean/develop
Release v1.9.0-r1
2017-03-03 11:28:02 -05:00
Jeremy Stretch
0e14bc1e02 Merge pull request #933 from digitalocean/develop
Release v1.9.0
2017-03-02 13:27:10 -05:00
Jeremy Stretch
ce6796ed9b Merge pull request #870 from digitalocean/develop
Release v1.8.4
2017-02-03 13:59:02 -05:00
Jeremy Stretch
c90cecc2fb Merge pull request #849 from digitalocean/develop
Release v1.8.3
2017-01-26 13:58:52 -05:00
Jeremy Stretch
b6bbcb0609 Merge pull request #814 from digitalocean/develop
Release v1.8.2
2017-01-18 16:23:28 -05:00
Jeremy Stretch
23f6832d9c Merge pull request #774 from digitalocean/develop
Release v1.8.1
2017-01-04 15:30:54 -05:00
Jeremy Stretch
88dace75a1 Merge pull request #766 from digitalocean/develop
Release v1.8.0
2017-01-03 15:13:36 -05:00
Jeremy Stretch
8eb140fd65 Merge pull request #736 from digitalocean/develop
Release v1.7.3
2016-12-08 12:34:53 -05:00
Jeremy Stretch
1f09f3d096 Merge pull request #728 from digitalocean/develop
Release v1.7.2-r1
2016-12-06 15:38:52 -05:00
Jeremy Stretch
66be85a41f Merge pull request #726 from digitalocean/develop
Release v1.7.2
2016-12-06 14:55:19 -05:00
Jeremy Stretch
814c11167e Merge pull request #694 from digitalocean/develop
Release v1.7.1
2016-11-15 12:34:09 -05:00
Jeremy Stretch
57ddd5086f Merge pull request #666 from digitalocean/develop
Release v1.7.0
2016-11-03 15:12:33 -04:00
Jeremy Stretch
c171547037 Merge pull request #625 from digitalocean/develop
Release v1.6.3
2016-10-19 16:25:50 -04:00
141 changed files with 2964 additions and 2357 deletions

View File

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

View File

@@ -10,7 +10,9 @@ Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https
### Build Status
| | python 2.7 |
NetBox is built against both Python 2.7 and 3.5. Python 3.5 is recommended.
| | status |
|-------------|------------|
| **master** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=master)](https://travis-ci.org/digitalocean/netbox) |
| **develop** | [![Build Status](https://travis-ci.org/digitalocean/netbox.svg?branch=develop)](https://travis-ci.org/digitalocean/netbox) |
@@ -29,5 +31,6 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
## Alternative Installations
* [Docker container](http://netbox.readthedocs.io/en/stable/installation/docker/)
* [Docker container](https://github.com/digitalocean/netbox-docker)
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,13 +5,14 @@
Python 3:
```no-highlight
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# update-alternatives --install /usr/bin/python python /usr/bin/python3 1
```
Python 2:
```no-highlight
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
# apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
```
**CentOS/RHEL**
@@ -20,7 +21,9 @@ Python 3:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python3 python3-devel python3-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
# easy_install-3.4 pip
# ln -s -f python3.4 /usr/bin/python
```
Python 2:
@@ -83,6 +86,14 @@ Checking connectivity... done.
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
Python 3:
```no-highlight
# pip3 install -r requirements.txt
```
Python 2:
```no-highlight
# pip install -r requirements.txt
```
@@ -172,7 +183,7 @@ Superuser created successfully.
# Collect Static Files
```no-highlight
# ./manage.py collectstatic
# ./manage.py collectstatic --no-input
You have requested to collect static files at the destination
location as specified in your settings:

View File

@@ -5,13 +5,14 @@ NetBox requires a PostgreSQL database to store data. (Please note that MySQL is
**Debian/Ubuntu**
```no-highlight
# apt-get install -y postgresql libpq-dev python-psycopg2
# apt-get update
# apt-get install -y postgresql libpq-dev
```
**CentOS/RHEL**
```no-highlight
# yum install -y postgresql postgresql-server postgresql-devel python-psycopg2
# yum install -y postgresql postgresql-server postgresql-devel
# postgresql-setup initdb
```

View File

@@ -25,7 +25,7 @@ server {
server_name netbox.example.com;
access_log off;
client_max_body_size 25m;
location /static/ {
alias /opt/netbox/netbox/static/;
@@ -73,6 +73,9 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
Alias /static /opt/netbox/netbox/static
# Needed to allow token-based API authentication
WSGIPassAuthorization on
<Directory /opt/netbox/netbox/static>
Options Indexes FollowSymLinks MultiViews
AllowOverride None

View File

@@ -8,7 +8,6 @@ pages:
- 'Web Server': 'installation/web-server.md'
- 'LDAP (Optional)': 'installation/ldap.md'
- 'Upgrading': 'installation/upgrading.md'
- 'Alternate Install: Docker': 'installation/docker.md'
- 'Configuration':
- 'Mandatory Settings': 'configuration/mandatory-settings.md'
- 'Optional Settings': 'configuration/optional-settings.md'

View File

@@ -28,11 +28,14 @@ class NestedProviderSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug']
class WritableProviderSerializer(serializers.ModelSerializer):
class WritableProviderSerializer(CustomFieldModelSerializer):
class Meta:
model = Provider
fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
fields = [
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
'custom_fields',
]
#
@@ -79,11 +82,14 @@ class NestedCircuitSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'cid']
class WritableCircuitSerializer(serializers.ModelSerializer):
class WritableCircuitSerializer(CustomFieldModelSerializer):
class Meta:
model = Circuit
fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
fields = [
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
'custom_fields',
]
#

View File

@@ -22,4 +22,5 @@ router.register(r'circuit-types', views.CircuitTypeViewSet)
router.register(r'circuits', views.CircuitViewSet)
router.register(r'circuit-terminations', views.CircuitTerminationViewSet)
app_name = 'circuits-api'
urlpatterns = router.urls

View File

@@ -3,10 +3,11 @@ from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea,
SlugField,
APISelect, BootstrapMixin, BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField,
FilterChoiceField, Livesearch, SmallTextarea, SlugField,
)
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -83,12 +84,15 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
# Circuits
#
class CircuitForm(BootstrapMixin, CustomFieldForm):
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
comments = CommentField()
class Meta:
model = Circuit
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
fields = [
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments',
]
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
@@ -152,15 +156,16 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Circuit terminations
#
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = forms.ModelChoiceField(
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'site'},
required=False,
label='Rack',
widget=APISelect(
@@ -168,8 +173,9 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
required=False,
label='Device',
widget=APISelect(
@@ -187,8 +193,11 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
field_to_update='device'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
interface = ChainedModelChoiceField(
queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
),
chains={'device': 'device'},
required=False,
label='Interface',
widget=APISelect(
@@ -212,49 +221,17 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
if instance and instance.interface is not None:
initial = kwargs.get('initial', {})
initial['rack'] = instance.interface.device.rack
initial['device'] = instance.interface.device
kwargs['initial'] = initial
super(CircuitTerminationForm, self).__init__(*args, **kwargs)
# If an interface has been assigned, initialize rack and device
if self.instance.interface:
self.initial['rack'] = self.instance.interface.device.rack
self.initial['device'] = self.instance.interface.device
# Limit rack choices
if self.is_bound:
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Limit device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Limit interface choices
if self.is_bound and self.data.get('device'):
interfaces = Interface.objects.filter(device=self.data['device']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
elif self.initial.get('device'):
interfaces = Interface.objects.filter(device=self.initial['device']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []
# Mark connected interfaces as disabled
self.fields['interface'].choices = [
(iface.id, {
'label': iface.name,
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
}) for iface in interfaces
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface'].queryset
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-04-19 17:17
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('circuits', '0007_circuit_add_description'),
]
operations = [
migrations.AlterField(
model_name='circuittermination',
name='interface',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_termination', to='dcim.Interface'),
),
]

View File

@@ -1,6 +1,6 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from dcim.fields import ASNField
@@ -150,10 +150,14 @@ class CircuitTermination(models.Model):
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True)
interface = models.OneToOneField(
'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT
)
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
help_text='Upstream speed, if different from port speed')
upstream_speed = models.PositiveIntegerField(
blank=True, null=True, verbose_name='Upstream speed (Kbps)',
help_text='Upstream speed, if different from port speed'
)
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')

View File

@@ -79,7 +79,13 @@ class CircuitSearchTable(SearchTable):
cid = tables.LinkColumn(verbose_name='ID')
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
a_side = tables.LinkColumn(
'dcim:site', accessor=Accessor('termination_a.site'), args=[Accessor('termination_a.site.slug')]
)
z_side = tables.LinkColumn(
'dcim:site', accessor=Accessor('termination_z.site'), args=[Accessor('termination_z.site.slug')]
)
class Meta(SearchTable.Meta):
model = Circuit
fields = ('cid', 'type', 'provider', 'tenant', 'description')
fields = ('cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')

View File

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

View File

@@ -1,10 +1,10 @@
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.forms import ConfirmationForm
@@ -95,7 +95,7 @@ class CircuitTypeEditView(PermissionRequiredMixin, ObjectEditView):
model = CircuitType
form_class = forms.CircuitTypeForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('circuits:circuittype_list')
@@ -142,7 +142,6 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuit'
model = Circuit
form_class = forms.CircuitForm
fields_initial = ['provider']
template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list'
@@ -230,7 +229,6 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuittermination'
model = CircuitTermination
form_class = forms.CircuitTerminationForm
fields_initial = ['term_side']
template_name = 'circuits/circuittermination_edit.html'
def alter_obj(self, obj, request, url_args, url_kwargs):
@@ -238,7 +236,7 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
return obj
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.circuit.get_absolute_url()

View File

@@ -66,13 +66,13 @@ class NestedSiteSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'slug']
class WritableSiteSerializer(serializers.ModelSerializer):
class WritableSiteSerializer(CustomFieldModelSerializer):
class Meta:
model = Site
fields = [
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments',
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
]
@@ -150,13 +150,13 @@ class NestedRackSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'display_name']
class WritableRackSerializer(serializers.ModelSerializer):
class WritableRackSerializer(CustomFieldModelSerializer):
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'comments',
'comments', 'custom_fields',
]
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
# prevents facility_id from being interpreted as a required field.
@@ -263,13 +263,13 @@ class NestedDeviceTypeSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'manufacturer', 'model', 'slug']
class WritableDeviceTypeSerializer(serializers.ModelSerializer):
class WritableDeviceTypeSerializer(CustomFieldModelSerializer):
class Meta:
model = DeviceType
fields = [
'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'interface_ordering',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments',
'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields',
]
@@ -476,13 +476,13 @@ class DeviceSerializer(CustomFieldModelSerializer):
}
class WritableDeviceSerializer(serializers.ModelSerializer):
class WritableDeviceSerializer(CustomFieldModelSerializer):
class Meta:
model = Device
fields = [
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments',
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
]
validators = []
@@ -490,7 +490,7 @@ class WritableDeviceSerializer(serializers.ModelSerializer):
# Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta.
if data.get('rack') and data.get('position') and data.get('face'):
validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('rack', 'position', 'face'))
validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face'))
validator.set_context(self)
validator(data)
@@ -581,9 +581,18 @@ class WritablePowerPortSerializer(serializers.ModelSerializer):
# 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):
device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
lag = NestedInterfaceSerializer()
connection = serializers.SerializerMethodField(read_only=True)
connected_interface = serializers.SerializerMethodField(read_only=True)
@@ -608,6 +617,7 @@ class InterfaceSerializer(serializers.ModelSerializer):
class PeerInterfaceSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
class Meta:
model = Interface

View File

@@ -58,4 +58,5 @@ router.register(r'interface-connections', views.InterfaceConnectionViewSet)
# Miscellaneous
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
app_name = 'dcim-api'
urlpatterns = router.urls

View File

@@ -2,16 +2,16 @@ from rest_framework.decorators import detail_route
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ReadOnlyModelViewSet, ViewSet
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
from django.conf import settings
from django.shortcuts import get_object_or_404
from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
RackRole, Region, Site,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site,
)
from dcim import filters
from extras.api.serializers import RenderedGraphSerializer
@@ -323,6 +323,7 @@ class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
serializer_class = serializers.InterfaceConnectionSerializer
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
filter_class = filters.InterfaceConnectionFilter
#

View File

@@ -8,9 +8,9 @@ from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceTemplate, Manufacturer, InventoryItem,
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
)
@@ -148,6 +148,33 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RackReservationFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='rack__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
group_id = NullableModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = NullableModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
@@ -158,6 +185,16 @@ class RackReservationFilter(django_filters.FilterSet):
model = RackReservation
fields = ['rack', 'user']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(rack__name__icontains=value) |
Q(rack__facility_id__icontains=value) |
Q(user__username__icontains=value) |
Q(description__icontains=value)
)
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
@@ -336,10 +373,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Platform (slug)',
)
status = django_filters.BooleanFilter(
name='status',
label='Status',
)
is_console_server = django_filters.BooleanFilter(
name='device_type__is_console_server',
label='Is a console server',
@@ -356,6 +389,9 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='_has_primary_ip',
label='Has a primary IP',
)
status = django_filters.MultipleChoiceFilter(
choices=STATUS_CHOICES
)
class Meta:
model = Device
@@ -401,7 +437,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
@@ -441,6 +477,11 @@ class InterfaceFilter(DeviceComponentFilterSet):
method='filter_type',
label='Interface type',
)
lag_id = django_filters.ModelMultipleChoiceFilter(
name='lag',
queryset=Interface.objects.all(),
label='LAG interface (ID)',
)
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
@@ -550,6 +591,10 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
label='Device',
)
class Meta:
model = InterfaceConnection
fields = ['connection_status']
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset

View File

@@ -1,6 +1,5 @@
import re
from mptt.forms import TreeNodeChoiceField
import re
from django import forms
from django.contrib.postgres.forms.array import SimpleArrayField
@@ -9,11 +8,13 @@ from django.db.models import Count, Q
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from ipam.models import IPAddress
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField,
CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled,
SmallTextarea, SlugField, FilterTreeNodeMultipleChoiceField,
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
BulkImportForm, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, CSVDataField, ExpandableNameField,
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField,
)
from .formfields import MACAddressFormField
@@ -21,9 +22,9 @@ from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate,
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Region, Site, STATUS_CHOICES,
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES,
)
@@ -81,7 +82,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm):
# Sites
#
class SiteForm(BootstrapMixin, CustomFieldForm):
class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
slug = SlugField()
comments = CommentField()
@@ -89,8 +90,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm):
class Meta:
model = Site
fields = [
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
'contact_name', 'contact_phone', 'contact_email', 'comments',
'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
]
widgets = {
'physical_address': SmallTextarea(attrs={'rows': 3}),
@@ -185,16 +186,23 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
# Racks
#
class RackForm(BootstrapMixin, CustomFieldForm):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}',
))
class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
group = ChainedModelChoiceField(
queryset=RackGroup.objects.all(),
chains={'site': 'site'},
required=False,
widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}',
)
)
comments = CommentField()
class Meta:
model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'comments']
fields = [
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units', 'comments',
]
help_texts = {
'site': "The site at which the rack exists",
'name': "Organizational rack name",
@@ -205,18 +213,6 @@ class RackForm(BootstrapMixin, CustomFieldForm):
'site': forms.Select(attrs={'filter-for': 'group'}),
}
def __init__(self, *args, **kwargs):
super(RackForm, self).__init__(*args, **kwargs)
# Limit rack group choices
if self.is_bound and self.data.get('site'):
self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].choices = []
class RackFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
@@ -272,6 +268,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
u_height = forms.IntegerField(required=False, label='Height (U)')
desc_units = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Descending units')
comments = CommentField(widget=SmallTextarea)
class Meta:
@@ -330,6 +327,19 @@ class RackReservationForm(BootstrapMixin, forms.ModelForm):
return unit_choices
class RackReservationFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('racks__reservations')),
to_field_name='slug'
)
group_id = FilterChoiceField(
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')),
label='Rack group',
null_option=(0, 'None')
)
#
# Manufacturers
#
@@ -362,7 +372,13 @@ class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
u_height = forms.IntegerField(min_value=1, required=False)
is_full_depth = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is full depth')
interface_ordering = forms.ChoiceField(choices=add_blank_choice(IFACE_ORDERING_CHOICES), required=False)
is_console_server = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is full depth')
is_pdu = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a PDU')
is_network_device = forms.NullBooleanField(
required=False, widget=BulkEditNullBooleanSelect, label='Is a network device'
)
class Meta:
nullable_fields = []
@@ -471,6 +487,7 @@ class InterfaceTemplateCreateForm(DeviceComponentForm):
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput)
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
class Meta:
nullable_fields = []
@@ -518,48 +535,77 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
# Devices
#
class DeviceForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name',
attrs={'filter-for': 'position'}
))
position = forms.TypedChoiceField(required=False, empty_value=None,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}',
disabled_indicator='device'))
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(),
widget=forms.Select(attrs={'filter-for': 'device_type'}))
device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Device type', widget=APISelect(
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
display_field='model'
))
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'site'},
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name',
attrs={'filter-for': 'position'}
)
)
position = forms.TypedChoiceField(
required=False,
empty_value=None,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/units/?face={{face}}',
disabled_indicator='device'
)
)
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
widget=forms.Select(
attrs={'filter-for': 'device_type'}
)
)
device_type = ChainedModelChoiceField(
queryset=DeviceType.objects.all(),
chains={'manufacturer': 'manufacturer'},
label='Device type',
widget=APISelect(
api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}',
display_field='model'
)
)
comments = CommentField()
class Meta:
model = Device
fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position',
'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments']
fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'status',
'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments',
]
help_texts = {
'device_role': "The function this device serves",
'serial': "Chassis serial number",
}
widgets = {
'face': forms.Select(attrs={'filter-for': 'position'}),
'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}),
}
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
if instance and hasattr(instance, 'device_type'):
initial = kwargs.get('initial', {})
initial['manufacturer'] = instance.device_type.manufacturer
kwargs['initial'] = initial
super(DeviceForm, self).__init__(*args, **kwargs)
if self.instance.pk:
# Initialize helper selections
self.initial['site'] = self.instance.site
self.initial['manufacturer'] = self.instance.device_type.manufacturer
# Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]:
ip_choices = []
@@ -582,14 +628,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True
# Limit rack choices
if self.is_bound and self.data.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Rack position
pk = self.instance.pk if self.instance.pk else None
try:
@@ -610,16 +648,6 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
}) for p in position_choices
]
# Limit device_type choices
if self.is_bound:
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\
.select_related('manufacturer')
elif self.initial.get('manufacturer'):
self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\
.select_related('manufacturer')
else:
self.fields['device_type'].choices = []
# Disable rack assignment if this is a child device installed in a parent device
if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'):
self.fields['site'].disabled = True
@@ -629,15 +657,24 @@ class DeviceForm(BootstrapMixin, CustomFieldForm):
class BaseDeviceFromCSVForm(forms.ModelForm):
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid device role.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid manufacturer.'})
device_role = forms.ModelChoiceField(
queryset=DeviceRole.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid device role.'}
)
tenant = forms.ModelChoiceField(
Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'}
)
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(), to_field_name='name',
error_messages={'invalid_choice': 'Invalid manufacturer.'}
)
model_name = forms.CharField()
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid platform.'})
platform = forms.ModelChoiceField(
queryset=Platform.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid platform.'}
)
status = forms.CharField()
class Meta:
fields = []
@@ -655,17 +692,28 @@ class BaseDeviceFromCSVForm(forms.ModelForm):
except DeviceType.DoesNotExist:
self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
class DeviceFromCSVForm(BaseDeviceFromCSVForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
'invalid_choice': 'Invalid site name.',
})
site = forms.ModelChoiceField(
queryset=Site.objects.all(), to_field_name='name', error_messages={
'invalid_choice': 'Invalid site name.',
}
)
rack_name = forms.CharField(required=False)
face = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta):
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag',
'site', 'rack_name', 'position', 'face']
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_name', 'position', 'face',
]
def clean(self):
@@ -707,8 +755,8 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
class Meta(BaseDeviceFromCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'parent',
'device_bay_name',
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay_name',
]
def clean(self):
@@ -752,6 +800,13 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['tenant', 'platform']
def device_status_choices():
status_counts = {}
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
q = forms.CharField(required=False, label='Search')
@@ -763,18 +818,21 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__devices')),
label='Rack group',
)
rack_id = FilterChoiceField(
queryset=Rack.objects.annotate(filter_count=Count('devices')),
label='Rack',
null_option=(0, 'None'),
)
role = FilterChoiceField(
queryset=DeviceRole.objects.annotate(filter_count=Count('devices')),
to_field_name='slug',
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug',
queryset=Tenant.objects.annotate(filter_count=Count('devices')),
to_field_name='slug',
null_option=(0, 'None'),
)
manufacturer_id = FilterChoiceField(
queryset=Manufacturer.objects.all(),
label='Manufacturer',
)
manufacturer_id = FilterChoiceField(queryset=Manufacturer.objects.all(), label='Manufacturer')
device_type_id = FilterChoiceField(
queryset=DeviceType.objects.select_related('manufacturer').order_by('model').annotate(
filter_count=Count('instances'),
@@ -786,14 +844,8 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
to_field_name='slug',
null_option=(0, 'None'),
)
status = forms.NullBooleanField(
required=False,
widget=forms.Select(choices=FORM_STATUS_CHOICES),
)
mac_address = forms.CharField(
required=False,
label='MAC address',
)
status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
mac_address = forms.CharField(required=False, label='MAC address')
#
@@ -901,21 +953,23 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm):
self.cleaned_data['csv'] = connection_list
class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.HiddenInput(),
)
rack = forms.ModelChoiceField(
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'site'},
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'console_server', 'nullable': 'true'}
)
)
console_server = forms.ModelChoiceField(
queryset=Device.objects.all(),
console_server = ChainedModelChoiceField(
queryset=Device.objects.filter(device_type__is_console_server=True),
chains={'site': 'site', 'rack': 'rack'},
label='Console Server',
required=False,
widget=APISelect(
@@ -933,11 +987,12 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
field_to_update='console_server',
)
)
cs_port = forms.ModelChoiceField(
cs_port = ChainedModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
chains={'device': 'console_server'},
label='Port',
widget=APISelect(
api_url='/api/dcim/console-server-ports/?device_id={{device}}',
api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
disabled_indicator='connected_console',
)
)
@@ -957,32 +1012,6 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm):
if not self.instance.pk:
raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.")
# Initialize rack choices if site is set
if self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Initialize console_server choices if rack or site is set
if self.initial.get('rack'):
self.fields['console_server'].queryset = Device.objects.filter(
rack=self.initial['rack'], device_type__is_console_server=True
)
elif self.initial.get('site'):
self.fields['console_server'].queryset = Device.objects.filter(
site=self.initial['site'], rack__isnull=True, device_type__is_console_server=True
)
else:
self.fields['console_server'].choices = []
# Initialize CS port choices if console_server is set
if self.initial.get('console_server'):
self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(
device=self.initial['console_server']
)
else:
self.fields['cs_port'].choices = []
#
# Console server ports
@@ -1002,21 +1031,23 @@ class ConsoleServerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.HiddenInput(),
)
rack = forms.ModelChoiceField(
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'site'},
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
label='Device',
required=False,
widget=APISelect(
@@ -1034,8 +1065,9 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
field_to_update='device'
)
)
port = forms.ModelChoiceField(
port = ChainedModelChoiceField(
queryset=ConsolePort.objects.all(),
chains={'device': 'device'},
label='Port',
widget=APISelect(
api_url='/api/dcim/console-ports/?device_id={{device}}',
@@ -1057,30 +1089,6 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
'connection_status': 'Status',
}
def __init__(self, *args, **kwargs):
super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs)
# Initialize rack choices if site is set
if self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Initialize device choices if rack or site is set
if self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
elif self.initial.get('site'):
self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True)
else:
self.fields['device'].choices = []
# Initialize port choices if device is set
if self.initial.get('device'):
self.fields['port'].queryset = ConsolePort.objects.filter(device=self.initial['device'])
else:
self.fields['port'].choices = []
#
# Power ports
@@ -1172,18 +1180,20 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
self.cleaned_data['csv'] = connection_list
class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput())
rack = forms.ModelChoiceField(
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'site'},
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'pdu', 'nullable': 'true'}
)
)
pdu = forms.ModelChoiceField(
pdu = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
label='PDU',
required=False,
widget=APISelect(
@@ -1201,11 +1211,12 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
field_to_update='pdu'
)
)
power_outlet = forms.ModelChoiceField(
power_outlet = ChainedModelChoiceField(
queryset=PowerOutlet.objects.all(),
chains={'device': 'pdu'},
label='Outlet',
widget=APISelect(
api_url='/api/dcim/power-outlets/?device_id={{device}}',
api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
disabled_indicator='connected_port'
)
)
@@ -1225,30 +1236,6 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
if not self.instance.pk:
raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.")
# Initialize rack choices if site is set
if self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Initialize pdu choices if rack or site is set
if self.initial.get('rack'):
self.fields['pdu'].queryset = Device.objects.filter(
rack=self.initial['rack'], device_type__is_pdu=True
)
elif self.initial.get('site'):
self.fields['pdu'].queryset = Device.objects.filter(
site=self.initial['site'], rack__isnull=True, device_type__is_pdu=True
)
else:
self.fields['pdu'].choices = []
# Initialize power outlet choices if pdu is set
if self.initial.get('pdu'):
self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device=self.initial['pdu'])
else:
self.fields['power_outlet'].choices = []
#
# Power outlets
@@ -1268,21 +1255,23 @@ class PowerOutletCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.HiddenInput()
)
rack = forms.ModelChoiceField(
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'site'},
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
label='Device',
required=False,
widget=APISelect(
@@ -1300,8 +1289,9 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
field_to_update='device'
)
)
port = forms.ModelChoiceField(
port = ChainedModelChoiceField(
queryset=PowerPort.objects.all(),
chains={'device': 'device'},
label='Port',
widget=APISelect(
api_url='/api/dcim/power-ports/?device_id={{device}}',
@@ -1323,30 +1313,6 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
'connection_status': 'Status',
}
def __init__(self, *args, **kwargs):
super(PowerOutletConnectionForm, self).__init__(*args, **kwargs)
# Initialize rack choices if site is set
if self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Initialize device choices if rack or site is set
if self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
elif self.initial.get('site'):
self.fields['device'].queryset = Device.objects.filter(site=self.initial['site'], rack__isnull=True)
else:
self.fields['device'].choices = []
# Initialize port choices if device is set
if self.initial.get('device'):
self.fields['port'].queryset = PowerPort.objects.filter(device=self.initial['device'])
else:
self.fields['port'].choices = []
#
# Interfaces
@@ -1400,6 +1366,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
description = forms.CharField(max_length=100, required=False)
class Meta:
@@ -1409,9 +1376,16 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces which belong to the parent device.
device = None
if self.initial.get('device'):
self.fields['lag'].queryset = Interface.objects.filter(
device=self.initial['device'], form_factor=IFACE_FF_LAG
try:
device = Device.objects.get(pk=self.initial.get('device'))
except Device.DoesNotExist:
pass
if device is not None:
interface_ordering = device.device_type.interface_ordering
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
device=device, form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].choices = []
@@ -1421,7 +1395,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
# Interface connections
#
class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
interface_a = forms.ChoiceField(
choices=[],
widget=SelectWithDisabled,
@@ -1435,8 +1409,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'rack_b'}
)
)
rack_b = forms.ModelChoiceField(
rack_b = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'site_b'},
label='Rack',
required=False,
widget=APISelect(
@@ -1444,8 +1419,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'device_b', 'nullable': 'true'}
)
)
device_b = forms.ModelChoiceField(
device_b = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'site_b', 'rack': 'rack_b'},
label='Device',
required=False,
widget=APISelect(
@@ -1463,12 +1439,15 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
field_to_update='device_b'
)
)
interface_b = forms.ModelChoiceField(
queryset=Interface.objects.all(),
interface_b = ChainedModelChoiceField(
queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
),
chains={'device': 'device_b'},
label='Interface',
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
disabled_indicator='is_connected'
disabled_indicator='connection'
)
)
@@ -1490,31 +1469,9 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
]
# Initialize rack_b choices if site_b is set
if self.initial.get('site_b'):
self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b'])
else:
self.fields['rack_b'].choices = []
# Initialize device_b choices if rack_b or site_b is set
if self.initial.get('rack_b'):
self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b'])
elif self.initial.get('site_b'):
self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True)
else:
self.fields['device_b'].choices = []
# Initialize interface_b choices if device_b is set
if self.initial.get('device_b'):
device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude(
form_factor__in=VIRTUAL_IFACE_TYPES
).select_related(
'circuit_termination', 'connected_as_a', 'connected_as_b'
)
else:
device_b_interfaces = []
# Mark connected interfaces as disabled
self.fields['interface_b'].choices = [
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface_b'].queryset
]
@@ -1671,36 +1628,6 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
device = forms.CharField(required=False, label='Device name')
#
# IP addresses
#
class IPAddressForm(BootstrapMixin, CustomFieldForm):
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'tenant', 'status', 'interface', 'description']
def __init__(self, device, *args, **kwargs):
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
interfaces = device.interfaces.all()
self.fields['interface'].queryset = interfaces
self.fields['interface'].required = True
# If this device has only one interface, select it by default.
if len(interfaces) == 1:
self.fields['interface'].initial = interfaces[0]
# If this device does not have any IP addresses assigned, default to setting the first IP as its primary.
if not IPAddress.objects.filter(interface__device=device).count():
self.fields['set_as_primary'].initial = True
#
# Inventory items
#

View File

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

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.1 on 2017-05-09 16:00
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0035_device_expand_status_choices'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus'], [5200, b'Juniper VCP']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[[b'Virtual interfaces', [[0, b'Virtual'], [200, b'Link Aggregation Group (LAG)']]], [b'Ethernet (fixed)', [[800, b'100BASE-TX (10/100ME)'], [1000, b'1000BASE-T (1GE)'], [1150, b'10GBASE-T (10GE)']]], [b'Ethernet (modular)', [[1050, b'GBIC (1GE)'], [1100, b'SFP (1GE)'], [1200, b'SFP+ (10GE)'], [1300, b'XFP (10GE)'], [1310, b'XENPAK (10GE)'], [1320, b'X2 (10GE)'], [1350, b'SFP28 (25GE)'], [1400, b'QSFP+ (40GE)'], [1500, b'CFP (100GE)'], [1600, b'QSFP28 (100GE)']]], [b'FibreChannel', [[3010, b'SFP (1GFC)'], [3020, b'SFP (2GFC)'], [3040, b'SFP (4GFC)'], [3080, b'SFP+ (8GFC)'], [3160, b'SFP+ (16GFC)']]], [b'Serial', [[4000, b'T1 (1.544 Mbps)'], [4010, b'E1 (2.048 Mbps)'], [4040, b'T3 (45 Mbps)'], [4050, b'E3 (34 Mbps)'], [4050, b'E3 (34 Mbps)']]], [b'Stacking', [[5000, b'Cisco StackWise'], [5050, b'Cisco StackWise Plus'], [5100, b'Cisco FlexStack'], [5150, b'Cisco FlexStack Plus'], [5200, b'Juniper VCP']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
]

View File

@@ -1,4 +1,5 @@
from collections import OrderedDict
from itertools import count, groupby
from mptt.models import MPTTModel, TreeForeignKey
@@ -8,10 +9,10 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from circuits.models import Circuit
@@ -101,6 +102,7 @@ IFACE_FF_STACKWISE = 5000
IFACE_FF_STACKWISE_PLUS = 5050
IFACE_FF_FLEXSTACK = 5100
IFACE_FF_FLEXSTACK_PLUS = 5150
IFACE_FF_JUNIPER_VCP = 5200
# Other
IFACE_FF_OTHER = 32767
@@ -162,6 +164,7 @@ IFACE_FF_CHOICES = [
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
[IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
]
],
[
@@ -177,13 +180,30 @@ VIRTUAL_IFACE_TYPES = [
IFACE_FF_LAG,
]
STATUS_ACTIVE = True
STATUS_OFFLINE = False
STATUS_OFFLINE = 0
STATUS_ACTIVE = 1
STATUS_PLANNED = 2
STATUS_STAGED = 3
STATUS_FAILED = 4
STATUS_INVENTORY = 5
STATUS_CHOICES = [
[STATUS_ACTIVE, 'Active'],
[STATUS_OFFLINE, 'Offline'],
[STATUS_PLANNED, 'Planned'],
[STATUS_STAGED, 'Staged'],
[STATUS_FAILED, 'Failed'],
[STATUS_INVENTORY, 'Inventory'],
]
DEVICE_STATUS_CLASSES = {
0: 'warning',
1: 'success',
2: 'info',
3: 'primary',
4: 'danger',
5: 'default',
}
CONNECTION_STATUS_PLANNED = False
CONNECTION_STATUS_CONNECTED = True
CONNECTION_STATUS_CHOICES = [
@@ -211,7 +231,9 @@ class Region(MPTTModel):
"""
Sites can be grouped within geographic Regions.
"""
parent = TreeForeignKey('self', null=True, blank=True, related_name='children', db_index=True)
parent = TreeForeignKey(
'self', null=True, blank=True, related_name='children', db_index=True, on_delete=models.CASCADE
)
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
@@ -314,7 +336,7 @@ class RackGroup(models.Model):
"""
name = models.CharField(max_length=50)
slug = models.SlugField()
site = models.ForeignKey('Site', related_name='rack_groups')
site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
class Meta:
ordering = ['site', 'name']
@@ -388,7 +410,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
]
def __str__(self):
return self.display_name
return self.display_name or super(Rack, self).__str__()
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@@ -445,7 +467,9 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
def display_name(self):
if self.facility_id:
return u"{} ({})".format(self.name, self.facility_id)
return self.name
elif self.name:
return self.name
return u""
def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
"""
@@ -573,6 +597,15 @@ class RackReservation(models.Model):
)
})
@property
def unit_list(self):
"""
Express the assigned units as a string of summarized ranges. For example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
#
# Device Types
@@ -779,13 +812,13 @@ class InterfaceManager(models.Manager):
def order_naturally(self, method=IFACE_ORDERING_POSITION):
"""
Naturally order interfaces by their name and numeric position. The sort method must be one of the defined
Naturally order interfaces by their type and numeric position. The sort method must be one of the defined
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
To order interfaces naturally, the `name` field is split into five distinct components: leading text (name),
slot, subslot, position, and channel:
To order interfaces naturally, the `name` field is split into six distinct components: leading text (type),
slot, subslot, position, channel, and virtual circuit:
{name}{slot}/{subslot}/{position}:{channel}
{type}{slot}/{subslot}/{position}:{channel}.{vc}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
be parsed as follows:
@@ -795,21 +828,24 @@ class InterfaceManager(models.Manager):
subslot = 0
position = 1
channel = None
vc = 0
The chosen sorting method will determine which fields are ordered first in the query.
The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of
the prescribed fields.
"""
queryset = self.get_queryset()
sql_col = '{}.name'.format(queryset.model._meta.db_table)
ordering = {
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'),
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'),
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
}[method]
return queryset.extra(select={
'_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col),
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col),
'_channel': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col),
'_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col),
'_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
}).order_by(*ordering)
@@ -919,19 +955,26 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
name = NullableCharField(max_length=64, blank=True, null=True, unique=True)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
help_text='A unique tag used to identify this device')
asset_tag = NullableCharField(
max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
help_text='A unique tag used to identify this device'
)
site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT)
rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT)
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
verbose_name='Position (U)',
help_text='The lowest-numbered unit occupied by the device')
position = models.PositiveSmallIntegerField(
blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)',
help_text='The lowest-numbered unit occupied by the device'
)
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IPv4')
primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IPv6')
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip4 = models.OneToOneField(
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
verbose_name='Primary IPv4'
)
primary_ip6 = models.OneToOneField(
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
verbose_name='Primary IPv6'
)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
@@ -943,7 +986,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
unique_together = ['rack', 'position', 'face']
def __str__(self):
return self.display_name
return self.display_name or super(Device, self).__str__()
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
@@ -1051,6 +1094,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
self.platform.name if self.platform else None,
self.serial,
self.asset_tag,
self.get_status_display(),
self.site.name,
self.rack.name if self.rack else None,
self.position,
@@ -1061,12 +1105,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
def display_name(self):
if self.name:
return self.name
elif self.position:
return u"{} ({} U{})".format(self.device_type, self.rack.name, self.position)
elif self.rack:
return u"{} ({})".format(self.device_type, self.rack.name)
else:
return u"{} ({})".format(self.device_type, self.site.name)
elif hasattr(self, 'device_type'):
return u"{}".format(self.device_type)
return u""
@property
def identifier(self):
@@ -1094,6 +1135,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
"""
return Device.objects.filter(parent_bay__device=self.pk)
def get_status_class(self):
return DEVICE_STATUS_CLASSES[self.status]
def get_rpc_client(self):
"""
Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined.

View File

@@ -6,7 +6,7 @@ from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, Region, Site,
RackGroup, RackReservation, Region, Site,
)
@@ -64,6 +64,12 @@ RACK_ROLE = """
{% endif %}
"""
RACKRESERVATION_ACTIONS = """
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
DEVICEROLE_ACTIONS = """
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -86,12 +92,8 @@ DEVICE_ROLE = """
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
"""
STATUS_ICON = """
{% if record.status %}
<span class="glyphicon glyphicon-ok-sign text-success" title="Active" aria-hidden="true"></span>
{% else %}
<span class="glyphicon glyphicon-minus-sign text-danger" title="Offline" aria-hidden="true"></span>
{% endif %}
DEVICE_STATUS = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
DEVICE_PRIMARY_IP = """
@@ -247,6 +249,23 @@ class RackImportTable(BaseTable):
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
#
# Rack reservations
#
class RackReservationTable(BaseTable):
pk = ToggleColumn()
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
unit_list = tables.Column(orderable=False, verbose_name='Units')
actions = tables.TemplateColumn(
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
)
class Meta(BaseTable.Meta):
model = RackReservation
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
#
# Manufacturers
#
@@ -409,7 +428,7 @@ class PlatformTable(BaseTable):
class DeviceTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(template_code=DEVICE_LINK)
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
@@ -429,7 +448,7 @@ class DeviceTable(BaseTable):
class DeviceSearchTable(SearchTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK)
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
@@ -446,6 +465,7 @@ class DeviceSearchTable(SearchTable):
class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
@@ -455,7 +475,7 @@ class DeviceImportTable(BaseTable):
class Meta(BaseTable.Meta):
model = Device
fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False

View File

@@ -8,6 +8,7 @@ from .models import Device, Rack, Site
from . import views
app_name = 'dcim'
urlpatterns = [
# Regions
@@ -39,11 +40,14 @@ urlpatterns = [
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
# Rack reservations
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
# Racks
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
url(r'^rack-elevations/$', views.RackElevationListView.as_view(), name='rack_elevation_list'),
url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'),
url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
@@ -118,7 +122,6 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),

View File

@@ -6,17 +6,21 @@ from operator import attrgetter
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db.models import Count
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.http import urlencode
from django.utils.safestring import mark_safe
from django.views.generic import View
from ipam.models import Prefix, IPAddress, Service, VLAN
from ipam.models import Prefix, Service, VLAN
from circuits.models import Circuit
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE, UserAction
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
@@ -101,6 +105,9 @@ class ComponentCreateView(View):
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
# Assign errors on the child form's name field to name_pattern on the parent form
if field == 'name':
field = 'name_pattern'
for e in errors:
form.add_error(field, u'{}: {}'.format(name, ', '.join(e)))
@@ -124,13 +131,13 @@ class ComponentCreateView(View):
class ComponentEditView(ObjectEditView):
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
class ComponentDeleteView(ObjectDeleteView):
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
@@ -149,7 +156,7 @@ class RegionEditView(PermissionRequiredMixin, ObjectEditView):
model = Region
form_class = forms.RegionForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:region_list')
@@ -242,7 +249,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView):
model = RackGroup
form_class = forms.RackGroupForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:rackgroup_list')
@@ -268,7 +275,7 @@ class RackRoleEditView(PermissionRequiredMixin, ObjectEditView):
model = RackRole
form_class = forms.RackRoleForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:rackrole_list')
@@ -291,6 +298,46 @@ class RackListView(ObjectListView):
template_name = 'dcim/rack_list.html'
class RackElevationListView(View):
"""
Display a set of rack elevations side-by-side.
"""
def get(self, request):
racks = Rack.objects.select_related(
'site', 'group', 'tenant', 'role'
).prefetch_related(
'devices__device_type'
)
racks = filters.RackFilter(request.GET, racks).qs
total_count = racks.count()
# Pagination
paginator = EnhancedPaginator(racks, 25)
page_number = request.GET.get('page', 1)
try:
page = paginator.page(page_number)
except PageNotAnInteger:
page = paginator.page(1)
except EmptyPage:
page = paginator.page(paginator.num_pages)
# Determine rack face
if request.GET.get('face') == '1':
face_id = 1
else:
face_id = 0
return render(request, 'dcim/rack_elevation_list.html', {
'paginator': paginator,
'page': page,
'total_count': total_count,
'face_id': face_id,
'filter_form': forms.RackFilterForm(request.GET),
})
def rack(request, pk):
rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
@@ -360,6 +407,14 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rack reservations
#
class RackReservationListView(ObjectListView):
queryset = RackReservation.objects.all()
filter = filters.RackReservationFilter
filter_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
template_name = 'dcim/rackreservation_list.html'
class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackreservation'
model = RackReservation
@@ -371,7 +426,7 @@ class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
obj.user = request.user
return obj
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.rack.get_absolute_url()
@@ -379,10 +434,16 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rackreservation'
model = RackReservation
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.rack.get_absolute_url()
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation'
cls = RackReservation
default_return_url = 'dcim:rackreservation_list'
#
# Manufacturers
#
@@ -398,7 +459,7 @@ class ManufacturerEditView(PermissionRequiredMixin, ObjectEditView):
model = Manufacturer
form_class = forms.ManufacturerForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:manufacturer_list')
@@ -618,7 +679,7 @@ class DeviceRoleEditView(PermissionRequiredMixin, ObjectEditView):
model = DeviceRole
form_class = forms.DeviceRoleForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:devicerole_list')
@@ -643,7 +704,7 @@ class PlatformEditView(PermissionRequiredMixin, ObjectEditView):
model = Platform
form_class = forms.PlatformForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('dcim:platform_list')
@@ -686,19 +747,15 @@ def device(request, pk):
interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
.filter(device=device, mgmt_only=False)\
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit')
'circuit_termination__circuit').prefetch_related('ip_addresses')
mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering)\
.filter(device=device, mgmt_only=True)\
.select_related('connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit')
'circuit_termination__circuit').prefetch_related('ip_addresses')
device_bays = natsorted(
DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
key=attrgetter('name')
)
# Gather relevant device objects
ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\
.order_by('address')
services = Service.objects.filter(device=device)
secrets = device.secrets.all()
@@ -729,7 +786,6 @@ def device(request, pk):
'interfaces': interfaces,
'mgmt_interfaces': mgmt_interfaces,
'device_bays': device_bays,
'ip_addresses': ip_addresses,
'services': services,
'secrets': secrets,
'related_devices': related_devices,
@@ -741,7 +797,6 @@ class DeviceEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_device'
model = Device
form_class = forms.DeviceForm
fields_initial = ['site', 'rack', 'position', 'face', 'device_bay']
template_name = 'dcim/device_edit.html'
default_return_url = 'dcim:device_list'
@@ -842,12 +897,16 @@ def consoleport_connect(request, pk):
form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport)
if form.is_valid():
consoleport = form.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
consoleport.device,
consoleport.name,
consoleport.cs_port.device,
consoleport.cs_port.name,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(),
escape(consoleport.device),
escape(consoleport.name),
consoleport.cs_port.device.get_absolute_url(),
escape(consoleport.cs_port.device),
escape(consoleport.cs_port.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk)
else:
@@ -871,17 +930,28 @@ def consoleport_disconnect(request, pk):
consoleport = get_object_or_404(ConsolePort, pk=pk)
if not consoleport.cs_port:
messages.warning(request, u"Cannot disconnect console port {}: It is not connected to anything."
.format(consoleport))
messages.warning(
request, u"Cannot disconnect console port {}: It is not connected to anything.".format(consoleport)
)
return redirect('dcim:device', pk=consoleport.device.pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
cs_port = consoleport.cs_port
consoleport.cs_port = None
consoleport.connection_status = None
consoleport.save()
messages.success(request, u"Console port {} has been disconnected.".format(consoleport))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(),
escape(consoleport.device),
escape(consoleport.name),
cs_port.device.get_absolute_url(),
escape(cs_port.device),
escape(cs_port.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleport.device.pk)
else:
@@ -916,6 +986,7 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.ConsoleConnectionImportForm
table = tables.ConsoleConnectionTable
template_name = 'dcim/console_connections_import.html'
default_return_url = 'dcim:console_connections_list'
#
@@ -943,12 +1014,16 @@ def consoleserverport_connect(request, pk):
consoleport.cs_port = consoleserverport
consoleport.connection_status = form.cleaned_data['connection_status']
consoleport.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
consoleport.device,
consoleport.name,
consoleserverport.device,
consoleserverport.name,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(),
escape(consoleport.device),
escape(consoleport.name),
consoleserverport.device.get_absolute_url(),
escape(consoleserverport.device),
escape(consoleserverport.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk)
else:
@@ -972,8 +1047,9 @@ def consoleserverport_disconnect(request, pk):
consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk)
if not hasattr(consoleserverport, 'connected_console'):
messages.warning(request, u"Cannot disconnect console server port {}: Nothing is connected to it."
.format(consoleserverport))
messages.warning(
request, u"Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport)
)
return redirect('dcim:device', pk=consoleserverport.device.pk)
if request.method == 'POST':
@@ -983,7 +1059,16 @@ def consoleserverport_disconnect(request, pk):
consoleport.cs_port = None
consoleport.connection_status = None
consoleport.save()
messages.success(request, u"Console server port {} has been disconnected.".format(consoleserverport))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
consoleport.device.get_absolute_url(),
escape(consoleport.device),
escape(consoleport.name),
consoleserverport.device.get_absolute_url(),
escape(consoleserverport.device),
escape(consoleserverport.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, consoleport, msg)
return redirect('dcim:device', pk=consoleserverport.device.pk)
else:
@@ -1035,12 +1120,16 @@ def powerport_connect(request, pk):
form = forms.PowerPortConnectionForm(request.POST, instance=powerport)
if form.is_valid():
powerport = form.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
powerport.device,
powerport.name,
powerport.power_outlet.device,
powerport.power_outlet.name,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(),
escape(powerport.device),
escape(powerport.name),
powerport.power_outlet.device.get_absolute_url(),
escape(powerport.power_outlet.device),
escape(powerport.power_outlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk)
else:
@@ -1064,17 +1153,28 @@ def powerport_disconnect(request, pk):
powerport = get_object_or_404(PowerPort, pk=pk)
if not powerport.power_outlet:
messages.warning(request, u"Cannot disconnect power port {}: It is not connected to an outlet."
.format(powerport))
messages.warning(
request, u"Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport)
)
return redirect('dcim:device', pk=powerport.device.pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
power_outlet = powerport.power_outlet
powerport.power_outlet = None
powerport.connection_status = None
powerport.save()
messages.success(request, u"Power port {} has been disconnected.".format(powerport))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(),
escape(powerport.device),
escape(powerport.name),
power_outlet.device.get_absolute_url(),
escape(power_outlet.device),
escape(power_outlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=powerport.device.pk)
else:
@@ -1109,6 +1209,7 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.PowerConnectionImportForm
table = tables.PowerConnectionTable
template_name = 'dcim/power_connections_import.html'
default_return_url = 'dcim:power_connections_list'
#
@@ -1136,12 +1237,16 @@ def poweroutlet_connect(request, pk):
powerport.power_outlet = poweroutlet
powerport.connection_status = form.cleaned_data['connection_status']
powerport.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
powerport.device,
powerport.name,
poweroutlet.device,
poweroutlet.name,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(),
escape(powerport.device),
escape(powerport.name),
poweroutlet.device.get_absolute_url(),
escape(poweroutlet.device),
escape(poweroutlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk)
else:
@@ -1165,7 +1270,9 @@ def poweroutlet_disconnect(request, pk):
poweroutlet = get_object_or_404(PowerOutlet, pk=pk)
if not hasattr(poweroutlet, 'connected_port'):
messages.warning(request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet))
messages.warning(
request, u"Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet)
)
return redirect('dcim:device', pk=poweroutlet.device.pk)
if request.method == 'POST':
@@ -1175,7 +1282,16 @@ def poweroutlet_disconnect(request, pk):
powerport.power_outlet = None
powerport.connection_status = None
powerport.save()
messages.success(request, u"Power outlet {} has been disconnected.".format(poweroutlet))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
powerport.device.get_absolute_url(),
escape(powerport.device),
escape(powerport.name),
poweroutlet.device.get_absolute_url(),
escape(poweroutlet.device),
escape(poweroutlet.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, powerport, msg)
return redirect('dcim:device', pk=poweroutlet.device.pk)
else:
@@ -1441,13 +1557,19 @@ def interfaceconnection_add(request, pk):
if request.method == 'POST':
form = forms.InterfaceConnectionForm(device, request.POST)
if form.is_valid():
interfaceconnection = form.save()
messages.success(request, u"Connected {} {} to {} {}.".format(
interfaceconnection.interface_a.device,
interfaceconnection.interface_a,
interfaceconnection.interface_b.device,
interfaceconnection.interface_b,
))
msg = u'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
interfaceconnection.interface_a.device.get_absolute_url(),
escape(interfaceconnection.interface_a.device),
escape(interfaceconnection.interface_a.name),
interfaceconnection.interface_b.device.get_absolute_url(),
escape(interfaceconnection.interface_b.device),
escape(interfaceconnection.interface_b.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
if '_addanother' in request.POST:
base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
device_b = interfaceconnection.interface_b.device
@@ -1485,12 +1607,16 @@ def interfaceconnection_delete(request, pk):
form = forms.InterfaceConnectionDeletionForm(request.POST)
if form.is_valid():
interfaceconnection.delete()
messages.success(request, u"Deleted the connection between {} {} and {} {}.".format(
interfaceconnection.interface_a.device,
interfaceconnection.interface_a,
interfaceconnection.interface_b.device,
interfaceconnection.interface_b,
))
msg = u'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
interfaceconnection.interface_a.device.get_absolute_url(),
escape(interfaceconnection.interface_a.device),
escape(interfaceconnection.interface_a.name),
interfaceconnection.interface_b.device.get_absolute_url(),
escape(interfaceconnection.interface_b.device),
escape(interfaceconnection.interface_b.name),
)
messages.success(request, mark_safe(msg))
UserAction.objects.log_edit(request.user, interfaceconnection, msg)
if form.cleaned_data['device']:
return redirect('dcim:device', pk=form.cleaned_data['device'].pk)
else:
@@ -1520,6 +1646,7 @@ class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView
form = forms.InterfaceConnectionImportForm
table = tables.InterfaceConnectionTable
template_name = 'dcim/interface_connections_import.html'
default_return_url = 'dcim:interface_connections_list'
#
@@ -1553,47 +1680,6 @@ class InterfaceConnectionsListView(ObjectListView):
template_name = 'dcim/interface_connections_list.html'
#
# IP addresses
#
@permission_required(['dcim.change_device', 'ipam.add_ipaddress'])
def ipaddress_assign(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == 'POST':
form = forms.IPAddressForm(device, request.POST)
if form.is_valid():
ipaddress = form.save(commit=False)
ipaddress.interface = form.cleaned_data['interface']
ipaddress.save()
form.save_custom_fields()
messages.success(request, u"Added new IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
if form.cleaned_data['set_as_primary']:
if ipaddress.family == 4:
device.primary_ip4 = ipaddress
elif ipaddress.family == 6:
device.primary_ip6 = ipaddress
device.save()
if '_addanother' in request.POST:
return redirect('dcim:ipaddress_assign', pk=device.pk)
else:
return redirect('dcim:device', pk=device.pk)
else:
form = forms.IPAddressForm(device)
return render(request, 'dcim/ipaddress_assign.html', {
'device': device,
'form': form,
'return_url': reverse('dcim:device', kwargs={'pk': device.pk}),
})
#
# Inventory items
#

View File

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

View File

@@ -29,4 +29,5 @@ router.register(r'image-attachments', views.ImageAttachmentViewSet)
# Recent activity
router.register(r'recent-activity', views.RecentActivityViewSet)
app_name = 'extras-api'
urlpatterns = router.urls

View File

@@ -6,7 +6,7 @@ from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from dcim.models import Device, InventoryItem, Site
from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
class Command(BaseCommand):
@@ -39,7 +39,7 @@ class Command(BaseCommand):
self.password = getpass("Password: ")
# Attempt to inventory only active devices
device_list = Device.objects.filter(status=True)
device_list = Device.objects.filter(status=STATUS_ACTIVE)
# --site: Include only devices belonging to specified site(s)
if options['site']:
@@ -72,7 +72,7 @@ class Command(BaseCommand):
# Skip inactive devices
if not device.status:
self.stdout.write("Skipped (inactive)")
self.stdout.write("Skipped (not active)")
continue
# Skip devices without primary_ip set

View File

@@ -13,6 +13,8 @@ from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
from utilities.utils import foreground_color
CUSTOMFIELD_MODELS = (
'site', 'rack', 'devicetype', 'device', # DCIM
@@ -156,16 +158,13 @@ class CustomField(models.Model):
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CF_TYPE_SELECT:
try:
return self.choices.get(pk=int(serialized_value))
except CustomFieldChoice.DoesNotExist:
return None
return self.choices.get(pk=int(serialized_value))
return serialized_value
@python_2_unicode_compatible
class CustomFieldValue(models.Model):
field = models.ForeignKey('CustomField', related_name='values')
field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE)
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
obj_id = models.PositiveIntegerField()
obj = GenericForeignKey('obj_type', 'obj_id')
@@ -254,7 +253,9 @@ class Graph(models.Model):
@python_2_unicode_compatible
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
content_type = models.ForeignKey(
ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}, on_delete=models.CASCADE
)
name = models.CharField(max_length=100)
description = models.CharField(max_length=200, blank=True)
template_code = models.TextField()
@@ -294,7 +295,7 @@ class ExportTemplate(models.Model):
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@@ -316,7 +317,8 @@ class TopologyMap(models.Model):
def render(self, img_format='png'):
from dcim.models import Device, InterfaceConnection
from circuits.models import CircuitTermination
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
# Construct the graph
graph = graphviz.Graph()
@@ -334,9 +336,11 @@ class TopologyMap(models.Model):
# Add each device to the graph
devices = []
for query in device_set.split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query)
devices += Device.objects.filter(name__regex=query).select_related('device_role')
for d in devices:
subgraph.node(d.name)
bg_color = '#{}'.format(d.device_role.color)
fg_color = '#{}'.format(foreground_color(d.device_role.color))
subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans')
# Add an invisible connection to each successive device in a set to enforce horizontal order
for j in range(0, len(devices) - 1):
@@ -350,13 +354,20 @@ class TopologyMap(models.Model):
for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query)
# Add all connections to the graph
# Add all interface connections to the graph
devices = Device.objects.filter(*(device_superset,))
connections = InterfaceConnection.objects.filter(
interface_a__device__in=devices, interface_b__device__in=devices
)
for c in connections:
graph.edge(c.interface_a.device.name, c.interface_b.device.name)
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
# Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
peer_termination = termination.get_peer_termination()
if peer_termination is not None and peer_termination.interface.device in devices:
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
return graph.pipe(format=img_format)
@@ -376,7 +387,7 @@ def image_upload(instance, filename):
elif instance.name:
filename = instance.name
return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
return u'{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
@python_2_unicode_compatible
@@ -384,7 +395,7 @@ class ImageAttachment(models.Model):
"""
An uploaded image which is associated with an object.
"""
content_type = models.ForeignKey(ContentType)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
parent = GenericForeignKey('content_type', 'object_id')
image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width')
@@ -415,6 +426,16 @@ class ImageAttachment(models.Model):
# before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
self.image.name = _name
@property
def size(self):
"""
Wrapper around `image.size` to suppress an OSError in case the file is inaccessible.
"""
try:
return self.image.size
except OSError:
return None
#
# User actions

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
return imageattachment
def get_return_url(self, imageattachment):
def get_return_url(self, request, imageattachment):
return imageattachment.parent.get_absolute_url()
@@ -26,5 +26,5 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_imageattachment'
model = ImageAttachment
def get_return_url(self, imageattachment):
return imageattachment.obj.get_absolute_url()
def get_return_url(self, request, imageattachment):
return imageattachment.parent.get_absolute_url()

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env python
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
import os
import random
charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
random.seed = (os.urandom(2048))
print(''.join(random.choice(charset) for c in range(50)))
secure_random = random.SystemRandom()
print(''.join(secure_random.sample(charset, 50)))

View File

@@ -31,11 +31,11 @@ class NestedVRFSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'name', 'rd']
class WritableVRFSerializer(serializers.ModelSerializer):
class WritableVRFSerializer(CustomFieldModelSerializer):
class Meta:
model = VRF
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description']
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
#
@@ -96,11 +96,11 @@ class NestedAggregateSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'prefix']
class WritableAggregateSerializer(serializers.ModelSerializer):
class WritableAggregateSerializer(CustomFieldModelSerializer):
class Meta:
model = Aggregate
fields = ['id', 'prefix', 'rir', 'date_added', 'description']
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
#
@@ -169,11 +169,11 @@ class NestedVLANSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'vid', 'name', 'display_name']
class WritableVLANSerializer(serializers.ModelSerializer):
class WritableVLANSerializer(CustomFieldModelSerializer):
class Meta:
model = VLAN
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
validators = []
def validate(self, data):
@@ -216,11 +216,14 @@ class NestedPrefixSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'prefix']
class WritablePrefixSerializer(serializers.ModelSerializer):
class WritablePrefixSerializer(CustomFieldModelSerializer):
class Meta:
model = Prefix
fields = ['id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description']
fields = [
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
'custom_fields',
]
#
@@ -252,11 +255,11 @@ IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
class WritableIPAddressSerializer(serializers.ModelSerializer):
class WritableIPAddressSerializer(CustomFieldModelSerializer):
class Meta:
model = IPAddress
fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside']
fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', 'custom_fields']
#

View File

@@ -37,4 +37,5 @@ router.register(r'vlans', views.VLANViewSet)
# Services
router.register(r'services', views.ServiceViewSet)
app_name = 'ipam-api'
urlpatterns = router.urls

View File

@@ -9,7 +9,10 @@ from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from .models import (
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
VLAN_STATUS_CHOICES, VLANGroup, VRF,
)
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -153,10 +156,13 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=PREFIX_STATUS_CHOICES
)
class Meta:
model = Prefix
fields = ['family', 'status']
fields = ['family']
def search(self, queryset, name, value):
if not value.strip():
@@ -237,10 +243,13 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=Interface.objects.all(),
label='Interface (ID)',
)
status = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_STATUS_CHOICES
)
class Meta:
model = IPAddress
fields = ['family', 'status']
fields = ['family']
def search(self, queryset, name, value):
if not value.strip():
@@ -337,10 +346,13 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=VLAN_STATUS_CHOICES
)
class Meta:
model = VLAN
fields = ['name', 'vid', 'status']
fields = ['name', 'vid']
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -1,12 +1,14 @@
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Count
from dcim.models import Site, Rack, Device, Interface
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch,
SlugField, add_blank_choice,
APISelect, BootstrapMixin, BulkEditNullBooleanSelect, BulkImportForm, ChainedModelChoiceField, CSVDataField,
ExpandableIPAddressField, FilterChoiceField, Livesearch, ReturnURLForm, SlugField, add_blank_choice,
)
from .models import (
@@ -32,11 +34,11 @@ IPADDRESS_MASK_LENGTH_CHOICES = PREFIX_MASK_LENGTH_CHOICES + [(128, 128)]
# VRFs
#
class VRFForm(BootstrapMixin, CustomFieldForm):
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = VRF
fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
fields = ['name', 'rd', 'enforce_unique', 'description', 'tenant_group', 'tenant']
labels = {
'rd': "RD",
}
@@ -61,6 +63,9 @@ class VRFImportForm(BootstrapMixin, BulkImportForm):
class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput)
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
enforce_unique = forms.NullBooleanField(
required=False, widget=BulkEditNullBooleanSelect, label='Enforce unique space'
)
description = forms.CharField(max_length=100, required=False)
class Meta:
@@ -160,30 +165,27 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
# Prefixes
#
class PrefixForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan', 'nullable': 'true'}))
vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN',
widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}',
display_field='display_name'))
class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, label='Site', widget=forms.Select(
attrs={'filter-for': 'vlan', 'nullable': 'true'}
)
)
vlan = ChainedModelChoiceField(
queryset=VLAN.objects.all(), chains={'site': 'site'}, required=False, label='VLAN', widget=APISelect(
api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name'
)
)
class Meta:
model = Prefix
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description']
fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant']
def __init__(self, *args, **kwargs):
super(PrefixForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Initialize field without choices to avoid pulling all VLANs from the database
if self.is_bound and self.data.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site'])
else:
self.fields['vlan'].queryset = VLAN.objects.filter(site=None)
class PrefixFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
@@ -194,14 +196,16 @@ class PrefixFromCSVForm(forms.ModelForm):
error_messages={'invalid_choice': 'Site not found.'})
vlan_group_name = forms.CharField(required=False)
vlan_vid = forms.IntegerField(required=False)
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES])
status = forms.CharField()
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'})
class Meta:
model = Prefix
fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool',
'description']
fields = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status', 'role', 'is_pool',
'description',
]
def clean(self):
@@ -210,35 +214,38 @@ class PrefixFromCSVForm(forms.ModelForm):
site = self.cleaned_data.get('site')
vlan_group_name = self.cleaned_data.get('vlan_group_name')
vlan_vid = self.cleaned_data.get('vlan_vid')
# Validate VLAN
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:
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
if vlan_vid and vlan_group:
if site:
self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
else:
self.add_error('vlan_group_name', "Invalid global VLAN group ({}).".format(vlan_group_name))
# Validate VLAN
if vlan_vid:
try:
self.instance.vlan = VLAN.objects.get(group=vlan_group, vid=vlan_vid)
self.instance.vlan = VLAN.objects.get(site=site, group=vlan_group, vid=vlan_vid)
except VLAN.DoesNotExist:
self.add_error('vlan_vid', "Invalid VLAN ID ({} - {}).".format(vlan_group, vlan_vid))
elif vlan_vid and site:
try:
self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
except VLAN.DoesNotExist:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
if site:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for site {}.".format(vlan_vid, site))
elif vlan_group:
self.add_error('vlan_vid', "Invalid VLAN ID ({}) for group {}.".format(vlan_vid, vlan_group_name))
elif not vlan_group_name:
self.add_error('vlan_vid', "Invalid global VLAN ID ({}).".format(vlan_vid))
except VLAN.MultipleObjectsReturned:
self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
elif vlan_vid:
self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
def save(self, *args, **kwargs):
# Assign Prefix status by name
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
return super(PrefixFromCSVForm, self).save(*args, **kwargs)
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in PREFIX_STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
class PrefixImportForm(BootstrapMixin, BulkImportForm):
@@ -252,6 +259,7 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
is_pool = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a pool')
description = forms.CharField(max_length=100, required=False)
class Meta:
@@ -302,131 +310,183 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
# IP addresses
#
class IPAddressForm(BootstrapMixin, CustomFieldForm):
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'}))
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress-list', field_to_update='nat_inside', obj_label='address')
)
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
widgets = {
'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
}
def __init__(self, *args, **kwargs):
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
if self.instance.nat_inside:
nat_inside = self.instance.nat_inside
# If the IP is assigned to an interface, populate site/device fields accordingly
if self.instance.nat_inside.interface:
self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
self.fields['nat_device'].queryset = Device.objects.filter(
site=nat_inside.interface.device.site
)
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device=nat_inside.interface.device
)
else:
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
else:
# Initialize nat_device choices if nat_site is set
if self.is_bound and self.data.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
elif self.initial.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
else:
self.fields['nat_device'].choices = []
# Initialize nat_inside choices if nat_device is set
if self.is_bound and self.data.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device__pk=self.data['nat_device'])
elif self.initial.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device__pk=self.initial['nat_device'])
else:
self.fields['nat_inside'].choices = []
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField()
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False)
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
interface_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
attrs={'filter-for': 'interface_rack'}
)
)
rack = forms.ModelChoiceField(
interface_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
chains={'site': 'interface_site'},
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
api_url='/api/dcim/racks/?site_id={{interface_site}}',
display_field='display_name',
attrs={'filter-for': 'device', 'nullable': 'true'}
attrs={'filter-for': 'interface_device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
interface_device = ChainedModelChoiceField(
queryset=Device.objects.all(),
label='Device',
chains={'site': 'interface_site', 'rack': 'interface_rack'},
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
interface = ChainedModelChoiceField(
queryset=Interface.objects.all(),
chains={'device': 'interface_device'},
required=False,
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
)
)
nat_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
widget=forms.Select(
attrs={'filter-for': 'nat_device'}
)
)
nat_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'nat_site'},
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{interface_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_device', 'nullable': 'true'}
)
)
nat_device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'nat_site'},
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'}
)
)
nat_inside = ChainedModelChoiceField(
queryset=IPAddress.objects.all(),
chains={'interface__device': 'nat_device'},
required=False,
label='IP Address',
widget=APISelect(
api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
display_field='address'
)
)
livesearch = forms.CharField(
required=False,
label='IP Address',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device-list',
field_to_update='device'
query_url='ipam-api:ipaddress-list',
field_to_update='nat_inside',
obj_label='address'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
label='Interface',
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{device}}'
)
)
set_as_primary = forms.BooleanField(
label='Set as primary IP for device',
required=False
)
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside', 'tenant_group',
'tenant',
]
def __init__(self, *args, **kwargs):
super(IPAddressAssignForm, self).__init__(*args, **kwargs)
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {})
if instance and instance.interface is not None:
initial['interface_site'] = instance.interface.device.site
initial['interface_rack'] = instance.interface.device.rack
initial['interface_device'] = instance.interface.device
if instance and instance.nat_inside is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
kwargs['initial'] = initial
self.fields['rack'].choices = []
self.fields['device'].choices = []
self.fields['interface'].choices = []
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Initialize primary_for_device if IP address is already assigned
if self.instance.interface is not None:
device = self.instance.interface.device
if (
self.instance.address.version == 4 and device.primary_ip4 == self.instance or
self.instance.address.version == 6 and device.primary_ip6 == self.instance
):
self.initial['primary_for_device'] = True
def clean(self):
super(IPAddressForm, self).clean()
# Primary IP assignment is only available if an interface has been assigned.
if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'):
self.add_error(
'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
def save(self, *args, **kwargs):
ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
# Assign this IPAddress as the primary for the associated Device.
if self.cleaned_data['primary_for_device']:
device = self.cleaned_data['interface'].device
if ipaddress.address.version == 4:
device.primary_ip4 = ipaddress
else:
device.primary_ip6 = ipaddress
device.save()
# Clear assignment as primary for device if set.
else:
try:
if ipaddress.address.version == 4:
device = ipaddress.primary_ip4_for
device.primary_ip4 = None
else:
device = ipaddress.primary_ip6_for
device.primary_ip6 = None
device.save()
except Device.DoesNotExist:
pass
return ipaddress
class IPAddressPatternForm(BootstrapMixin, forms.Form):
pattern = ExpandableIPAddressField(label='Address pattern')
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = IPAddress
fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant']
def __init__(self, *args, **kwargs):
super(IPAddressBulkAddForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class IPAddressFromCSVForm(forms.ModelForm):
@@ -434,7 +494,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
error_messages={'invalid_choice': 'VRF not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in IPADDRESS_STATUS_CHOICES])
status = forms.CharField()
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Device not found.'})
interface_name = forms.CharField(required=False)
@@ -442,7 +502,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
class Meta:
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):
@@ -465,10 +525,14 @@ class IPAddressFromCSVForm(forms.ModelForm):
if is_primary and not device:
self.add_error('is_primary', "No device specified; cannot set as primary IP")
def save(self, *args, **kwargs):
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in IPADDRESS_STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
# Assign status by name
self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
def save(self, *args, **kwargs):
# Set interface
if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
@@ -552,14 +616,27 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
# VLANs
#
class VLANForm(BootstrapMixin, CustomFieldForm):
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
))
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=forms.Select(
attrs={'filter-for': 'group', 'nullable': 'true'}
)
)
group = ChainedModelChoiceField(
queryset=VLANGroup.objects.all(),
chains={'site': 'site'},
required=False,
label='Group',
widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
class Meta:
model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant']
help_texts = {
'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)",
@@ -568,45 +645,58 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
'status': "Operational status of this VLAN",
'role': "The primary function of this VLAN",
}
widgets = {
'site': forms.Select(attrs={'filter-for': 'group', 'nullable': 'true'}),
}
def __init__(self, *args, **kwargs):
super(VLANForm, self).__init__(*args, **kwargs)
# Limit VLAN group choices
if self.is_bound and self.data.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
else:
self.fields['group'].queryset = VLANGroup.objects.filter(site=None)
class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'})
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'VLAN group not found.'})
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'})
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'})
site = forms.ModelChoiceField(
queryset=Site.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Site not found.'}
)
group_name = forms.CharField(required=False)
tenant = forms.ModelChoiceField(
Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'}
)
status = forms.CharField()
role = forms.ModelChoiceField(
queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'}
)
class Meta:
model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
def clean(self):
super(VLANFromCSVForm, self).clean()
# Validate VLANGroup
group_name = self.cleaned_data.get('group_name')
if group_name:
try:
VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
except VLANGroup.DoesNotExist:
self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
def clean_status(self):
status_choices = {s[1].lower(): s[0] for s in VLAN_STATUS_CHOICES}
try:
return status_choices[self.cleaned_data['status'].lower()]
except KeyError:
raise ValidationError("Invalid status: {}".format(self.cleaned_data['status']))
def save(self, *args, **kwargs):
m = super(VLANFromCSVForm, self).save(commit=False)
# Assign VLAN status by name
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
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'])
if kwargs.get('commit'):
m.save()
return m
vlan.save()
return vlan
class VLANImportForm(BootstrapMixin, BulkImportForm):
@@ -623,7 +713,7 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'description']
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
def vlan_status_choices():

View File

@@ -3,10 +3,10 @@ from netaddr import IPNetwork, cidr_merge
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.expressions import RawSQL
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from dcim.models import Interface
@@ -538,7 +538,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
verbose_name_plural = 'VLANs'
def __str__(self):
return self.display_name
return self.display_name or super(VLAN, self).__str__()
def get_absolute_url(self):
return reverse('ipam:vlan', args=[self.pk])
@@ -565,7 +565,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@property
def display_name(self):
return u'{} ({})'.format(self.vid, self.name)
if self.vid and self.name:
return u"{} ({})".format(self.vid, self.name)
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]

View File

@@ -70,9 +70,18 @@ IPADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
{% 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 %}
{{ record.0 }}
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
{% endif %}
"""
IPADDRESS_DEVICE = """
{% if record.interface %}
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
({{ record.interface.name }})
{% else %}
&mdash;
{% endif %}
"""
@@ -281,12 +290,14 @@ class IPAddressTable(BaseTable):
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK)
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
interface = tables.Column(orderable=False)
nat_inside = tables.LinkColumn(
'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
)
device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'nat_inside', 'device', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
}

View File

@@ -3,6 +3,7 @@ from django.conf.urls import url
from . import views
app_name = 'ipam'
urlpatterns = [
# VRFs
@@ -57,8 +58,6 @@ urlpatterns = [
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^ip-addresses/(?P<pk>\d+)/remove/$', views.ipaddress_remove, name='ipaddress_remove'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
# VLAN groups

View File

@@ -2,15 +2,12 @@ from django_tables2 import RequestConfig
import netaddr
from django.conf import settings
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from dcim.models import Device
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -244,7 +241,7 @@ class RIREditView(PermissionRequiredMixin, ObjectEditView):
model = RIR
form_class = forms.RIRForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('ipam:rir_list')
@@ -370,7 +367,7 @@ class RoleEditView(PermissionRequiredMixin, ObjectEditView):
model = Role
form_class = forms.RoleForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('ipam:role_list')
@@ -464,7 +461,6 @@ class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
model = Prefix
form_class = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
fields_initial = ['vrf', 'tenant', 'site', 'prefix', 'vlan']
default_return_url = 'ipam:prefix_list'
@@ -529,6 +525,7 @@ def prefix_ipaddresses(request, pk):
'prefix': prefix,
'ip_table': ip_table,
'permissions': permissions,
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix),
})
@@ -537,7 +534,7 @@ def prefix_ipaddresses(request, pk):
#
class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
@@ -572,80 +569,10 @@ def ipaddress(request, pk):
})
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
def ipaddress_assign(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = forms.IPAddressAssignForm(request.POST)
if form.is_valid():
interface = form.cleaned_data['interface']
ipaddress.interface = interface
ipaddress.save()
messages.success(request, u"Assigned IP address {} to interface {}.".format(ipaddress, ipaddress.interface))
if form.cleaned_data['set_as_primary']:
device = interface.device
if ipaddress.family == 4:
device.primary_ip4 = ipaddress
elif ipaddress.family == 6:
device.primary_ip6 = ipaddress
device.save()
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
assert False, form.errors
else:
form = forms.IPAddressAssignForm()
return render(request, 'ipam/ipaddress_assign.html', {
'ipaddress': ipaddress,
'form': form,
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
@permission_required(['dcim.change_device', 'ipam.change_ipaddress'])
def ipaddress_remove(request, pk):
ipaddress = get_object_or_404(IPAddress, pk=pk)
if request.method == 'POST':
form = ConfirmationForm(request.POST)
if form.is_valid():
device = ipaddress.interface.device
ipaddress.interface = None
ipaddress.save()
messages.success(request, u"Removed IP address {} from {}.".format(ipaddress, device))
if device.primary_ip4 == ipaddress.pk:
device.primary_ip4 = None
device.save()
elif device.primary_ip6 == ipaddress.pk:
device.primary_ip6 = None
device.save()
return redirect('ipam:ipaddress', pk=ipaddress.pk)
else:
form = ConfirmationForm()
return render(request, 'ipam/ipaddress_unassign.html', {
'ipaddress': ipaddress,
'form': form,
'return_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}),
})
class IPAddressEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.change_ipaddress'
model = IPAddress
form_class = forms.IPAddressForm
fields_initial = ['address', 'vrf']
template_name = 'ipam/ipaddress_edit.html'
default_return_url = 'ipam:ipaddress_list'
@@ -658,8 +585,9 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkAddForm
model = IPAddress
pattern_form = forms.IPAddressPatternForm
model_form = forms.IPAddressBulkAddForm
pattern_target = 'address'
template_name = 'ipam/ipaddress_bulk_add.html'
default_return_url = 'ipam:ipaddress_list'
@@ -718,7 +646,7 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
model = VLANGroup
form_class = forms.VLANGroupForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('ipam:vlangroup_list')
@@ -807,7 +735,7 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
return obj
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()

View File

@@ -13,7 +13,7 @@ except ImportError:
)
VERSION = '2.0-beta2'
VERSION = '2.0.3'
# Import local configuration
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None

View File

@@ -1,3 +1,5 @@
from rest_framework_swagger.views import get_swagger_view
from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
@@ -8,6 +10,7 @@ from users.views import login, logout
handler500 = handle_500
swagger_view = get_swagger_view(title='NetBox API')
_patterns = [
@@ -20,23 +23,23 @@ _patterns = [
url(r'^logout/$', logout, name='logout'),
# Apps
url(r'^circuits/', include('circuits.urls', namespace='circuits')),
url(r'^dcim/', include('dcim.urls', namespace='dcim')),
url(r'^extras/', include('extras.urls', namespace='extras')),
url(r'^ipam/', include('ipam.urls', namespace='ipam')),
url(r'^secrets/', include('secrets.urls', namespace='secrets')),
url(r'^tenancy/', include('tenancy.urls', namespace='tenancy')),
url(r'^user/', include('users.urls', namespace='user')),
url(r'^circuits/', include('circuits.urls')),
url(r'^dcim/', include('dcim.urls')),
url(r'^extras/', include('extras.urls')),
url(r'^ipam/', include('ipam.urls')),
url(r'^secrets/', include('secrets.urls')),
url(r'^tenancy/', include('tenancy.urls')),
url(r'^user/', include('users.urls')),
# API
url(r'^api/$', APIRootView.as_view(), name='api-root'),
url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')),
url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')),
url(r'^api/extras/', include('extras.api.urls', namespace='extras-api')),
url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')),
url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')),
url(r'^api/docs/', include('rest_framework_swagger.urls')),
url(r'^api/circuits/', include('circuits.api.urls')),
url(r'^api/dcim/', include('dcim.api.urls')),
url(r'^api/extras/', include('extras.api.urls')),
url(r'^api/ipam/', include('ipam.api.urls')),
url(r'^api/secrets/', include('secrets.api.urls')),
url(r'^api/tenancy/', include('tenancy.api.urls')),
url(r'^api/docs/', swagger_view, name='api_docs'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
@@ -45,7 +48,7 @@ _patterns = [
url(r'^500/$', trigger_500),
# Admin
url(r'^admin/', include(admin.site.urls)),
url(r'^admin/', admin.site.urls),
]

View File

@@ -1,3 +1,4 @@
from collections import OrderedDict
import sys
from rest_framework.views import APIView
@@ -13,7 +14,7 @@ from circuits.tables import CircuitSearchTable, ProviderSearchTable
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
from dcim.tables import DeviceSearchTable, DeviceTypeSearchTable, RackSearchTable, SiteSearchTable
from extras.models import UserAction
from extras.models import TopologyMap, UserAction
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from ipam.tables import AggregateSearchTable, IPAddressSearchTable, PrefixSearchTable, VLANSearchTable, VRFSearchTable
@@ -27,91 +28,91 @@ from .forms import SearchForm
SEARCH_MAX_RESULTS = 15
SEARCH_TYPES = {
SEARCH_TYPES = OrderedDict((
# Circuits
'provider': {
('provider', {
'queryset': Provider.objects.all(),
'filter': ProviderFilter,
'table': ProviderSearchTable,
'url': 'circuits:provider_list',
},
'circuit': {
'queryset': Circuit.objects.select_related('type', 'provider', 'tenant'),
}),
('circuit', {
'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'),
'filter': CircuitFilter,
'table': CircuitSearchTable,
'url': 'circuits:circuit_list',
},
}),
# DCIM
'site': {
('site', {
'queryset': Site.objects.select_related('region', 'tenant'),
'filter': SiteFilter,
'table': SiteSearchTable,
'url': 'dcim:site_list',
},
'rack': {
}),
('rack', {
'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'),
'filter': RackFilter,
'table': RackSearchTable,
'url': 'dcim:rack_list',
},
'devicetype': {
}),
('devicetype', {
'queryset': DeviceType.objects.select_related('manufacturer'),
'filter': DeviceTypeFilter,
'table': DeviceTypeSearchTable,
'url': 'dcim:devicetype_list',
},
'device': {
}),
('device', {
'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'),
'filter': DeviceFilter,
'table': DeviceSearchTable,
'url': 'dcim:device_list',
},
}),
# IPAM
'vrf': {
('vrf', {
'queryset': VRF.objects.select_related('tenant'),
'filter': VRFFilter,
'table': VRFSearchTable,
'url': 'ipam:vrf_list',
},
'aggregate': {
}),
('aggregate', {
'queryset': Aggregate.objects.select_related('rir'),
'filter': AggregateFilter,
'table': AggregateSearchTable,
'url': 'ipam:aggregate_list',
},
'prefix': {
}),
('prefix', {
'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
'filter': PrefixFilter,
'table': PrefixSearchTable,
'url': 'ipam:prefix_list',
},
'ipaddress': {
}),
('ipaddress', {
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
'filter': IPAddressFilter,
'table': IPAddressSearchTable,
'url': 'ipam:ipaddress_list',
},
'vlan': {
}),
('vlan', {
'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'),
'filter': VLANFilter,
'table': VLANSearchTable,
'url': 'ipam:vlan_list',
},
}),
# Secrets
'secret': {
('secret', {
'queryset': Secret.objects.select_related('role', 'device'),
'filter': SecretFilter,
'table': SecretSearchTable,
'url': 'secrets:secret_list',
},
}),
# Tenancy
'tenant': {
('tenant', {
'queryset': Tenant.objects.select_related('group'),
'filter': TenantFilter,
'table': TenantSearchTable,
'url': 'tenancy:tenant_list',
},
}
}),
))
def home(request):
@@ -148,6 +149,7 @@ def home(request):
return render(request, 'home.html', {
'search_form': SearchForm(),
'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
'recent_activity': UserAction.objects.select_related('user')[:50]
})
@@ -190,7 +192,7 @@ class SearchView(View):
results.append({
'name': queryset.model._meta.verbose_name_plural,
'table': table,
'url': '{}?q={}'.format(reverse(url), form.cleaned_data['q'])
'url': u'{}?q={}'.format(reverse(url), form.cleaned_data['q'])
})
return render(request, 'search.html', {

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 */
label {
font-weight: normal;
@@ -316,6 +323,16 @@ li.occupied + li.available {
border-top: 1px solid #474747;
}
/* Devices */
table.component-list tr.ipaddress td {
background-color: #eeffff;
padding-bottom: 4px;
padding-top: 4px;
}
table.component-list tr.ipaddress:hover td {
background-color: #e6f7f7;
}
/* Misc */
.banner-bottom {
margin-bottom: 50px;

View File

@@ -80,7 +80,7 @@ $(document).ready(function() {
child_field.append($("<option></option>").attr("value", "").text("---------"));
if ($(this).val() || $(this).attr('nullable') == 'true') {
var api_url = child_field.attr('api-url');
var api_url = child_field.attr('api-url') + '&limit=1000';
var disabled_indicator = child_field.attr('disabled-indicator');
var initial_value = child_field.attr('initial');
var display_field = child_field.attr('display-field') || 'name';
@@ -88,20 +88,21 @@ $(document).ready(function() {
// Determine the filter fields needed to make an API call
var filter_regex = /\{\{([a-z_]+)\}\}/g;
var match;
var rendered_url = api_url;
while (match = filter_regex.exec(api_url)) {
var filter_field = $('#id_' + match[1]);
if (filter_field.val()) {
api_url = api_url.replace(match[0], filter_field.val());
} else if ($(this).attr('nullable') == 'true') {
api_url = api_url.replace(match[0], '0');
rendered_url = rendered_url.replace(match[0], filter_field.val());
} else if (filter_field.attr('nullable') == 'true') {
rendered_url = rendered_url.replace(match[0], '0');
}
}
// If all URL variables have been replaced, make the API call
if (api_url.search('{{') < 0) {
console.log(child_name + ": Fetching " + api_url);
if (rendered_url.search('{{') < 0) {
console.log(child_name + ": Fetching " + rendered_url);
$.ajax({
url: api_url,
url: rendered_url,
dataType: 'json',
success: function(response, status) {
$.each(response.results, function(index, choice) {

View File

@@ -1,15 +1,29 @@
$(document).ready(function() {
// Unlocking a secret
$('button.unlock-secret').click(function() {
$('button.unlock-secret').click(function(event) {
var secret_id = $(this).attr('secret-id');
unlock_secret(secret_id);
event.preventDefault();
});
// Locking a secret
$('button.lock-secret').click(function() {
$('button.lock-secret').click(function(event) {
var secret_id = $(this).attr('secret-id');
lock_secret(secret_id);
event.preventDefault();
});
// Adding/editing a secret
$('form').submit(function(event) {
$(this).find('.requires-session-key').each(function() {
if (this.value && document.cookie.indexOf('session_key') == -1) {
console.log('Field ' + this.value + ' requires a session key');
$('#privkey_modal').modal('show');
event.preventDefault();
return false;
}
});
});
// Retrieve a session key

View File

@@ -2,7 +2,7 @@ from django.contrib import admin, messages
from django.shortcuts import redirect, render
from .forms import ActivateUserKeyForm
from .models import UserKey, SecretRole, Secret
from .models import UserKey
@admin.register(UserKey)
@@ -10,7 +10,7 @@ class UserKeyAdmin(admin.ModelAdmin):
actions = ['activate_selected']
list_display = ['user', 'is_filled', 'is_active', 'created']
fields = ['user', 'public_key', 'is_active', 'last_updated']
readonly_fields = ['is_active', 'last_updated']
readonly_fields = ['user', 'is_active', 'last_updated']
def get_readonly_fields(self, request, obj=None):
# Don't allow a user to modify an existing public key directly.

View File

@@ -22,4 +22,5 @@ router.register(r'secrets', views.SecretViewSet)
router.register(r'get-session-key', views.GetSessionKeyViewSet, base_name='get-session-key')
router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, base_name='generate-rsa-key-pair')
app_name = 'secrets-api'
urlpatterns = router.urls

View File

@@ -48,7 +48,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
class SecretForm(BootstrapMixin, forms.ModelForm):
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
widget=forms.PasswordInput())
widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
widget=forms.PasswordInput())
@@ -82,7 +82,7 @@ class SecretFromCSVForm(forms.ModelForm):
class SecretImportForm(BootstrapMixin, BulkImportForm):
csv = CSVDataField(csv_form=SecretFromCSVForm)
csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-session-key'}))
class SecretBulkEditForm(BootstrapMixin, BulkEditForm):

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.6 on 2017-03-14 17:19
# Generated by Django 1.11 on 2017-04-27 15:26
from __future__ import unicode_literals
from django.conf import settings
@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
('created', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['user__username'],
'ordering': ['userkey__user__username'],
},
),
migrations.AlterField(

View File

@@ -6,8 +6,8 @@ from django.conf import settings
from django.contrib.auth.hashers import make_password, check_password
from django.contrib.auth.models import Group, User
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.db import models
from django.urls import reverse
from django.utils.encoding import force_bytes, python_2_unicode_compatible
from dcim.models import Device
@@ -69,7 +69,7 @@ class UserKey(CreatedUpdatedModel):
copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's
matching (private) decryption key.
"""
user = models.OneToOneField(User, related_name='user_key', editable=False)
user = models.OneToOneField(User, related_name='user_key', editable=False, on_delete=models.CASCADE)
public_key = models.TextField(verbose_name='RSA public key')
master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False)
@@ -195,7 +195,7 @@ class SessionKey(models.Model):
key = None
class Meta:
ordering = ['user__username']
ordering = ['userkey__user__username']
def __str__(self):
return self.userkey.user.username
@@ -283,7 +283,7 @@ class Secret(CreatedUpdatedModel):
A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum
of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
"""
device = models.ForeignKey(Device, related_name='secrets')
device = models.ForeignKey(Device, related_name='secrets', on_delete=models.CASCADE)
role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT)
name = models.CharField(max_length=100, blank=True)
ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded

View File

@@ -44,6 +44,7 @@ class SecretTable(BaseTable):
class SecretSearchTable(SearchTable):
device = tables.LinkColumn()
class Meta(SearchTable.Meta):
model = Secret

View File

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

View File

@@ -3,10 +3,10 @@ import base64
from django.contrib import messages
from django.contrib.auth.decorators import permission_required, login_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction, IntegrityError
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator
from dcim.models import Device
@@ -14,7 +14,17 @@ from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, Obje
from . import filters, forms, tables
from .decorators import userkey_required
from .models import SecretRole, Secret, SessionKey, UserKey
from .models import SecretRole, Secret, SessionKey
def get_session_key(request):
"""
Extract and decode the session key sent with a request. Returns None if no session key was provided.
"""
session_key = request.COOKIES.get('session_key', None)
if session_key is not None:
return base64.b64decode(session_key)
return session_key
#
@@ -32,7 +42,7 @@ class SecretRoleEditView(PermissionRequiredMixin, ObjectEditView):
model = SecretRole
form_class = forms.SecretRoleForm
def get_return_url(self, obj):
def get_return_url(self, request, obj):
return reverse('secrets:secretrole_list')
@@ -73,14 +83,13 @@ def secret_add(request, pk):
device = get_object_or_404(Device, pk=pk)
secret = Secret(device=device)
uk = UserKey.objects.get(user=request.user)
session_key = get_session_key(request)
if request.method == 'POST':
form = forms.SecretForm(request.POST, instance=secret)
if form.is_valid():
# We need a valid session key in order to create a Secret
session_key = base64.b64decode(request.COOKIES.get('session_key', None))
if session_key is None:
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
@@ -119,13 +128,13 @@ def secret_add(request, pk):
def secret_edit(request, pk):
secret = get_object_or_404(Secret, pk=pk)
session_key = get_session_key(request)
if request.method == 'POST':
form = forms.SecretForm(request.POST, instance=secret)
if form.is_valid():
# Re-encrypt the Secret if a plaintext and session key have been provided.
session_key = base64.b64decode(request.COOKIES.get('session_key', None))
if form.cleaned_data['plaintext'] and session_key is not None:
# Retrieve the master key using the provided session key
@@ -212,6 +221,7 @@ def secret_import(request):
return render(request, 'import_success.html', {
'table': table,
'return_url': 'secrets:secret_list',
})
except IntegrityError as e:
@@ -222,7 +232,7 @@ def secret_import(request):
return render(request, 'secrets/secret_import.html', {
'form': form,
'return_url': reverse('secrets:secret_list'),
'return_url': 'secrets:secret_list',
})

View File

@@ -58,6 +58,7 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'dcim:rack_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Racks</a></li>
<li><a href="{% url 'dcim:rack_elevation_list' %}"><i class="fa fa-bars" aria-hidden="true"></i> Rack Elevations</a></li>
{% if perms.dcim.add_rack %}
<li><a href="{% url 'dcim:rack_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack</a></li>
<li><a href="{% url 'dcim:rack_import' %}"><i class="fa fa-download" aria-hidden="true"></i> Import Racks</a></li>
@@ -72,6 +73,8 @@
{% if perms.dcim.add_rackrole %}
<li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:rackreservation_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Reservations</a></li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/' %} active{% endif %}">
@@ -240,21 +243,11 @@
{% endif %}
</ul>
{% endif %}
<form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" role="search">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search">
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<i class="fa fa-search" aria-hidden="true"></i>
</button>
</span>
</div>
</form>
<ul class="nav navbar-nav navbar-right">
{% if request.user.is_authenticated %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
{{ request.user }} <span class="caret"></span>
<a href="#" class="dropdown-toggle" data-toggle="dropdown" title="{{ request.user }}" role="button" aria-haspopup="true" aria-expanded="false">
{{ request.user|truncatechars:"30" }} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li><a href="{% url 'user:profile' %}"><i class="fa fa-user" aria-hidden="true"></i> Profile</a></li>
@@ -269,13 +262,23 @@
<li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in" aria-hidden="true"></i> Log in</a></li>
{% endif %}
</ul>
<form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" id="navbar_search" role="search">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search">
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<i class="fa fa-search" aria-hidden="true"></i>
</button>
</span>
</div>
</form>
</div>
</div>
</nav>
<div class="container wrapper">
<div class="container wrapper">
{% if settings.BANNER_TOP %}
<div class="alert alert-info text-center" role="alert">
{{ settings.BANNER_TOP|safe }}
{{ settings.BANNER_TOP|safe }}
</div>
{% endif %}
{% if settings.MAINTENANCE_MODE %}
@@ -284,24 +287,24 @@
<p>NetBox is currently in maintenance mode. Functionality may be limited.</p>
</div>
{% endif %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissable" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
{{ message|safe }}
</div>
{% endfor %}
{% block content %}{% endblock %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissable" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
{{ message }}
</div>
{% endfor %}
{% block content %}{% endblock %}
<div class="push"></div>
{% if settings.BANNER_BOTTOM %}
<div class="alert alert-info text-center banner-bottom" role="alert">
{% if settings.BANNER_BOTTOM %}
<div class="alert alert-info text-center banner-bottom" role="alert">
{{ settings.BANNER_BOTTOM|safe }}
</div>
{% endif %}
</div>
<footer class="footer">
<div class="container">
</div>
<footer class="footer">
<div class="container">
<div class="row">
<div class="col-xs-4">
<p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
@@ -312,14 +315,14 @@
<div class="col-xs-4 text-right">
<p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> &middot;
<i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
</p>
</div>
</div>
</div>
</footer>
</div>
</footer>
<script type="text/javascript">
var netbox_api_path = "/{{ settings.BASE_PATH }}api/";
</script>

View File

@@ -8,12 +8,18 @@
{% render_field form.provider %}
{% render_field form.cid %}
{% render_field form.type %}
{% render_field form.tenant %}
{% render_field form.install_date %}
{% render_field form.commit_rate %}
{% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@@ -1,72 +1,55 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Circuit Import{% endblock %}
{% block content %}
<h1>Circuit Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<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>
</div>
</div>
{% 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 %}

View File

@@ -1,62 +1,45 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Provider Import{% endblock %}
{% block content %}
<h1>Provider Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Name</td>
<td>Provider's proper name</td>
<td>Level 3</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>level3</td>
</tr>
<tr>
<td>ASN</td>
<td>Autonomous system number (optional)</td>
<td>3356</td>
</tr>
<tr>
<td>Account</td>
<td>Account number (optional)</td>
<td>08931544</td>
</tr>
<tr>
<td>Portal URL</td>
<td>Customer service portal URL (optional)</td>
<td>https://mylevel3.net</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Level 3,level3,3356,08931544,https://mylevel3.net</pre>
</div>
</div>
{% 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>Name</td>
<td>Provider's proper name</td>
<td>Level 3</td>
</tr>
<tr>
<td>Slug</td>
<td>URL-friendly name</td>
<td>level3</td>
</tr>
<tr>
<td>ASN</td>
<td>Autonomous system number (optional)</td>
<td>3356</td>
</tr>
<tr>
<td>Account</td>
<td>Account number (optional)</td>
<td>08931544</td>
</tr>
<tr>
<td>Portal URL</td>
<td>Customer service portal URL (optional)</td>
<td>https://mylevel3.net</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>Level 3,level3,3356,08931544,https://mylevel3.net</pre>
{% endblock %}

View File

@@ -1,61 +1,45 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Console Connections Import{% endblock %}
{% block content %}
<h1>Console Connections Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Console server</td>
<td>Device name or {ID}</td>
<td>abc1-cs3</td>
</tr>
<tr>
<td>Console server port</td>
<td>Full CS port name</td>
<td>Port 35</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Console Port</td>
<td>Console port name</td>
<td>Console</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-cs3,Port 35,abc1-switch7,Console,planned</pre>
</div>
</div>
{% 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>Console server</td>
<td>Device name or {ID}</td>
<td>abc1-cs3</td>
</tr>
<tr>
<td>Console server port</td>
<td>Full CS port name</td>
<td>Port 35</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Console Port</td>
<td>Console port name</td>
<td>Console</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-cs3,Port 35,abc1-switch7,Console,planned</pre>
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Console Connections{% endblock %}
@@ -16,7 +15,7 @@
<h1>Console Connections</h1>
<div class="row">
<div class="col-md-9">
{% render_table table 'table.html' %}
{% include 'responsive_table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% block title %}{{ device }}{% endblock %}
@@ -123,11 +122,7 @@
<tr>
<td>Status</td>
<td>
{% if device.status %}
<span class="label label-success">{{ device.get_status_display }}</span>
{% else %}
<span class="label label-danger">{{ device.get_status_display }}</span>
{% endif %}
<span class="label label-{{ device.get_status_class }}">{{ device.get_status_display }}</span>
</td>
</tr>
<tr>
@@ -194,35 +189,6 @@
{% endif %}
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>IP Addresses</strong>
</div>
{% if ip_addresses %}
<table class="table table-hover panel-body">
{% for ip in ip_addresses %}
{% include 'dcim/inc/ipaddress.html' %}
{% endfor %}
</table>
{% elif interfaces or mgmt_interfaces %}
<div class="panel-body text-muted">
None assigned
</div>
{% else %}
<div class="panel-body">
<a href="{% url 'dcim:interface_add' pk=device.pk %}">Create an interface</a> to assign an IP.
</div>
{% endif %}
{% if perms.ipam.add_ipaddress %}
{% if interfaces or mgmt_interfaces %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:ipaddress_assign' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign IP address
</a>
</div>
{% endif %}
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Services</strong>
@@ -250,7 +216,7 @@
<div class="panel-heading">
<strong>Critical Connections</strong>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body component-list">
{% for iface in mgmt_interfaces %}
{% include 'dcim/inc/interface.html' with icon='wrench' %}
{% empty %}
@@ -389,7 +355,7 @@
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body component-list">
{% for devicebay in device_bays %}
{% include 'dcim/inc/devicebay.html' with selectable=True %}
{% empty %}
@@ -430,6 +396,9 @@
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right">
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
{% if perms.dcim.change_interface and interfaces|length > 1 %}
<button class="btn btn-default btn-xs toggle">
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
@@ -442,7 +411,7 @@
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
<table id="interfaces_table" class="table table-hover panel-body component-list">
{% for iface in interfaces %}
{% include 'dcim/inc/interface.html' with selectable=True %}
{% empty %}
@@ -499,7 +468,7 @@
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body component-list">
{% for csp in cs_ports %}
{% include 'dcim/inc/consoleserverport.html' with selectable=True %}
{% empty %}
@@ -551,7 +520,7 @@
{% endif %}
</div>
</div>
<table class="table table-hover panel-body">
<table class="table table-hover panel-body component-list">
{% for po in power_outlets %}
{% include 'dcim/inc/poweroutlet.html' with selectable=True %}
{% empty %}
@@ -642,6 +611,18 @@ $(".powerport-toggle").click(function() {
$(".interface-toggle").click(function() {
return toggleConnection($(this), "dcim/interface-connections/");
});
// Toggle the display of IP addresses under interfaces
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddress').hide();
} else {
$('#interfaces_table tr.ipaddress').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
return false;
});
</script>
<script src="{% static 'js/graphs.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>

View File

@@ -7,7 +7,6 @@
<div class="panel-body">
{% render_field form.name %}
{% render_field form.device_role %}
{% render_field form.tenant %}
</div>
</div>
<div class="panel panel-default">
@@ -55,14 +54,21 @@
<div class="panel panel-default">
<div class="panel-heading"><strong>Management</strong></div>
<div class="panel-body">
{% render_field form.platform %}
{% render_field form.status %}
{% render_field form.platform %}
{% if obj.pk %}
{% render_field form.primary_ip4 %}
{% render_field form.primary_ip6 %}
{% endif %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Device Import{% endblock %}
@@ -12,8 +11,12 @@
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
<h4>CSV Format</h4>
@@ -66,6 +69,11 @@
<td>Unique alphanumeric tag (optional)</td>
<td>ABC123456</td>
</tr>
<tr>
<td>Status</td>
<td>Current status</td>
<td>Active</td>
</tr>
<tr>
<td>Site</td>
<td>Site name</td>
@@ -89,7 +97,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Ashburn-VA,R101,21,Rear</pre>
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Active,Ashburn-VA,R101,21,Rear</pre>
</div>
</div>
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Device Import{% endblock %}
@@ -12,8 +11,12 @@
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
<div class="col-md-12 text-right">
<button type="submit" class="btn btn-primary">Submit</button>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
{% endif %}
</div>
</div>
</form>
<h4>CSV Format</h4>

View File

@@ -31,24 +31,84 @@
{% block javascript %}
<script type="text/javascript">
$(document).ready(function() {
var site_list = $('#id_site');
var rack_group_list = $('#id_rack_group_id');
var rack_list = $('#id_rack_id');
var manufacturer_list = $('#id_manufacturer_id');
var model_list = $('#id_device_type_id');
$('#id_manufacturer_id').change(function() {
model_list.empty();
// Update device type options based on selected manufacturer
manufacturer_list.change(function() {
var selected_manufacturers = $(this).val();
if (selected_manufacturers) {
var api_url = netbox_api_path + 'dcim/device-types/?manufacturer_id=' + selected_manufacturers.join('&manufacturer_id=');
model_list.empty();
$.ajax({
url: api_url,
url: netbox_api_path + 'dcim/device-types/?limit=500&manufacturer_id=' + selected_manufacturers.join('&manufacturer_id='),
dataType: 'json',
success: function (response, status) {
$.each(response, function (index, device_type) {
var option = $("<option></option>").attr("value", device_type.id).text(device_type["model"] + " (" + device_type["instance_count"] + ")");
$.each(response["results"], function (index, device_type) {
var option = $("<option></option>").attr("value", device_type.id).text(device_type.model + " (" + device_type.instance_count + ")");
model_list.append(option);
});
}
});
}
});
// Update rack group and rack options based on selected site
site_list.change(function() {
var selected_sites = $(this).val();
if (selected_sites) {
// Update rack group options
rack_group_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/rack-groups/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, group) {
var option = $("<option></option>").attr("value", group.id).text(group.name);
rack_group_list.append(option);
});
}
});
// Update rack options
rack_list.empty();
rack_list.append($("<option></option>").attr("value", "0").text("None"));
$.ajax({
url: netbox_api_path + 'dcim/racks/?limit=500&site=' + selected_sites.join('&site='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, rack) {
var option = $("<option></option>").attr("value", rack.id).text(rack.display_name);
rack_list.append(option);
});
}
});
}
});
// Update rack options based on selected rack group
rack_group_list.change(function() {
var selected_rack_groups = $(this).val();
if (selected_rack_groups) {
rack_list.empty();
$.ajax({
url: netbox_api_path + 'dcim/racks/?limit=500&group_id=' + selected_rack_groups.join('&group_id='),
dataType: 'json',
success: function (response, status) {
$.each(response["results"], function (index, rack) {
var option = $("<option></option>").attr("value", rack.id).text(rack.display_name);
rack_list.append(option);
});
}
});
}
});
});
</script>
{% endblock %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}

View File

@@ -1,4 +1,4 @@
<tr{% if cp.cs_port and not cp.connection_status %} class="info"{% endif %}>
<tr class="consoleport{% if cp.cs_port and not cp.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ cp.pk }}" />
@@ -20,7 +20,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_consoleport %}
{% if cp.cs_port %}
{% if cp.connection_status %}
@@ -33,11 +33,11 @@
</a>
{% endif %}
<a href="{% url 'dcim:consoleport_disconnect' pk=cp.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Delete connection"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true" title="Delete connection"></i>
</a>
{% else %}
<a href="{% url 'dcim:consoleport_connect' pk=cp.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Connect"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true" title="Connect"></i>
</a>
{% endif %}
<a href="{% url 'dcim:consoleport_edit' pk=cp.pk %}" class="btn btn-info btn-xs">

View File

@@ -1,4 +1,4 @@
<tr{% if csp.connected_console and not csp.connected_console.connection_status %} class="info"{% endif %}>
<tr class="consoleserverport{% if csp.connected_console and not csp.connected_console.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ csp.pk }}" />
@@ -7,6 +7,7 @@
<td>
<i class="fa fa-fw fa-keyboard-o"></i> {{ csp.name }}
</td>
<td></td>
{% if csp.connected_console %}
<td>
<a href="{% url 'dcim:device' pk=csp.connected_console.device.pk %}">{{ csp.connected_console.device }}</a>
@@ -19,7 +20,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_consoleserverport %}
{% if csp.connected_console %}
{% if csp.connected_console.connection_status %}
@@ -32,11 +33,11 @@
</a>
{% endif %}
<a href="{% url 'dcim:consoleserverport_disconnect' pk=csp.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Delete connection"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true" title="Delete connection"></i>
</a>
{% else %}
<a href="{% url 'dcim:consoleserverport_connect' pk=csp.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Connect"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true" title="Connect"></i>
</a>
{% endif %}
<a href="{% url 'dcim:consoleserverport_edit' pk=csp.pk %}" class="btn btn-info btn-xs">

View File

@@ -1,4 +1,4 @@
<tr>
<tr class="devicebay">
{% if selectable and perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ devicebay.pk }}" />
@@ -19,7 +19,7 @@
<span class="text-muted">Vacant</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_devicebay %}
{% if devicebay.installed_device %}
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">

View File

@@ -1,4 +1,3 @@
{% load render_table from django_tables2 %}
{% if perms.dcim.change_devicetype %}
<form method="post">
{% csrf_token %}
@@ -19,7 +18,7 @@
{% endif %}
</div>
</div>
{% render_table table 'table.html' %}
{% include 'responsive_table.html' %}
<div class="panel-footer">
{% if table.rows %}
{% if edit_url %}
@@ -48,6 +47,6 @@
<div class="panel-heading">
<strong>{{ title }}</strong>
</div>
{% render_table table 'table.html' %}
{% include 'responsive_table.html' %}
</div>
{% endif %}

View File

@@ -1,4 +1,4 @@
<tr{% if iface.connection and not iface.connection.connection_status %} class="info"{% endif %}>
<tr class="interface{% if iface.connection and not iface.connection.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ iface.pk }}" />
@@ -12,14 +12,14 @@
{% if iface.description %}
<i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
{% endif %}
{% if iface.is_lag %}
<br /><small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
{% endif %}
</td>
<td>
<small>{{ iface.mac_address|default:'' }}</small>
</td>
{% if iface.is_virtual %}
<td>{{ iface.mac_address|default:"" }}</td>
{% if iface.is_lag %}
<td colspan="2" class="text-muted">
LAG interface<br />
<small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
</td>
{% elif iface.is_virtual %}
<td colspan="2" class="text-muted">Virtual interface</td>
{% elif iface.connection %}
{% with iface.connected_interface as connected_iface %}
@@ -51,7 +51,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if show_graphs %}
{% if iface.circuit_termination or iface.connection %}
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs">
@@ -59,6 +59,11 @@
</button>
{% endif %}
{% endif %}
{% if perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?interface_site={{ device.site.pk }}&interface_rack={{ device.rack.pk }}&interface_device={{ device.pk }}&interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.change_interface %}
{% if not iface.is_virtual %}
{% if iface.connection %}
@@ -71,19 +76,19 @@
<i class="fa fa-plug" aria-hidden="true"></i>
</a>
{% endif %}
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Disconnect">
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
<button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
</button>
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
{% endif %}
{% endif %}
@@ -104,3 +109,41 @@
{% endif %}
</td>
</tr>
{% for ip in iface.ip_addresses.all %}
<tr class="ipaddress">
{% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
<td></td>
{% endif %}
<td colspan="2">
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
{% if ip.description %}
<i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
{% endif %}
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>
<td class="text-right">
{% if ip.vrf %}
<a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
{% else %}
<span class="text-muted">Global</span>
{% endif %}
</td>
<td>
<span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
</td>
<td class="text-right">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
</a>
{% endif %}
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}

View File

@@ -1,21 +0,0 @@
<tr>
<td>
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
</td>
<td>
{{ ip.vrf|default:"Global" }}
</td>
<td>{{ ip.interface }}</td>
<td>
{% if device.primary_ip4 == ip or device.primary_ip6 == ip %}
<span class="label label-success">Primary</span>
{% endif %}
</td>
<td class="text-right">
{% if perms.ipam.delete_ipaddress %}
<a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
</a>
{% endif %}
</td>
</tr>

View File

@@ -1,4 +1,4 @@
<tr{% if po.connected_port and not po.connected_port.connection_status %} class="info"{% endif %}>
<tr class="poweroutlet{% if po.connected_port and not po.connected_port.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ po.pk }}" />
@@ -7,6 +7,7 @@
<td>
<i class="fa fa-fw fa-bolt"></i> {{ po.name }}
</td>
<td></td>
{% if po.connected_port %}
<td>
<a href="{% url 'dcim:device' pk=po.connected_port.device.pk %}">{{ po.connected_port.device }}</a>
@@ -19,7 +20,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_poweroutlet %}
{% if po.connected_port %}
{% if po.connected_port.connection_status %}
@@ -32,11 +33,11 @@
</a>
{% endif %}
<a href="{% url 'dcim:poweroutlet_disconnect' pk=po.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Delete connection"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true" title="Delete connection"></i>
</a>
{% else %}
<a href="{% url 'dcim:poweroutlet_connect' pk=po.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Connect"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true" title="Connect"></i>
</a>
{% endif %}
<a href="{% url 'dcim:poweroutlet_edit' pk=po.pk %}" class="btn btn-info btn-xs">

View File

@@ -1,4 +1,4 @@
<tr{% if pp.power_outlet and not pp.connection_status %} class="info"{% endif %}>
<tr class="powerport{% if pp.power_outlet and not pp.connection_status %} info{% endif %}">
{% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ pp.pk }}" />
@@ -20,7 +20,7 @@
<span class="text-muted">Not connected</span>
</td>
{% endif %}
<td class="text-right">
<td colspan="2" class="text-right">
{% if perms.dcim.change_powerport %}
{% if pp.power_outlet %}
{% if pp.connection_status %}
@@ -33,11 +33,11 @@
</a>
{% endif %}
<a href="{% url 'dcim:powerport_disconnect' pk=pp.pk %}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-remove" aria-hidden="true" title="Delete connection"></i>
<i class="glyphicon glyphicon-resize-full" aria-hidden="true" title="Delete connection"></i>
</a>
{% else %}
<a href="{% url 'dcim:powerport_connect' pk=pp.pk %}" class="btn btn-success btn-xs">
<i class="glyphicon glyphicon-plus" aria-hidden="true" title="Connect"></i>
<i class="glyphicon glyphicon-resize-small" aria-hidden="true" title="Connect"></i>
</a>
{% endif %}
<a href="{% url 'dcim:powerport_edit' pk=pp.pk %}" class="btn btn-info btn-xs">

View File

@@ -1,69 +1,45 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Interface Connections Import{% endblock %}
{% block content %}
<h1>Interface Connections Import</h1>
<div class="row">
<div class="col-md-6">
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Device A</td>
<td>Device name or {ID}</td>
<td>abc1-core1</td>
</tr>
<tr>
<td>Interface A</td>
<td>Interface name</td>
<td>xe-0/0/6</td>
</tr>
<tr>
<td>Device B</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Interface B</td>
<td>Interface name</td>
<td>xe-0/0/0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-core1,xe-0/0/6,abc1-switch7,xe-0/0/0,planned</pre>
</div>
</div>
{% 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>Device A</td>
<td>Device name or {ID}</td>
<td>abc1-core1</td>
</tr>
<tr>
<td>Interface A</td>
<td>Interface name</td>
<td>xe-0/0/6</td>
</tr>
<tr>
<td>Device B</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Interface B</td>
<td>Interface name</td>
<td>xe-0/0/0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>planned</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-core1,xe-0/0/6,abc1-switch7,xe-0/0/0,planned</pre>
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Interface Connections{% endblock %}
@@ -16,7 +15,7 @@
<h1>Interface Connections</h1>
<div class="row">
<div class="col-md-9">
{% render_table table 'table.html' %}
{% include 'responsive_table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}

View File

@@ -1,61 +1,45 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Power Connections Import{% endblock %}
{% block content %}
<h1>Power Connections Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>PDU</td>
<td>Device name or {ID}</td>
<td>abc1-pdu1</td>
</tr>
<tr>
<td>Power Outlet</td>
<td>Power outlet name</td>
<td>AC4</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Power Port</td>
<td>Power port name</td>
<td>PSU0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>connected</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-pdu1,AC4,abc1-switch7,PSU0,connected</pre>
</div>
</div>
{% 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>PDU</td>
<td>Device name or {ID}</td>
<td>abc1-pdu1</td>
</tr>
<tr>
<td>Power Outlet</td>
<td>Power outlet name</td>
<td>AC4</td>
</tr>
<tr>
<td>Device</td>
<td>Device name or {ID}</td>
<td>abc1-switch7</td>
</tr>
<tr>
<td>Power Port</td>
<td>Power port name</td>
<td>PSU0</td>
</tr>
<tr>
<td>Connection Status</td>
<td>"planned" or "connected"</td>
<td>connected</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>abc1-pdu1,AC4,abc1-switch7,PSU0,connected</pre>
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Power Connections{% endblock %}
@@ -16,7 +15,7 @@
<h1>Power Connections</h1>
<div class="row">
<div class="col-md-9">
{% render_table table 'table.html' %}
{% include 'responsive_table.html' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% block title %}{{ rack.site }} - Rack {{ rack.name }}{% endblock %}
@@ -224,7 +223,7 @@
</tr>
{% for resv in reservations %}
<tr>
<td>{{ resv.units|join:', ' }}</td>
<td>{{ resv.unit_list }}</td>
<td>
{{ resv.description }}<br />
<small>{{ resv.user }} &middot; {{ resv.created }}</small>

View File

@@ -6,11 +6,22 @@
<div class="panel-heading"><strong>Rack</strong></div>
<div class="panel-body">
{% render_field form.site %}
{% render_field form.group %}
{% render_field form.name %}
{% render_field form.facility_id %}
{% render_field form.tenant %}
{% render_field form.group %}
{% render_field form.role %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Dimensions</strong></div>
<div class="panel-body">
{% render_field form.type %}
{% render_field form.width %}
{% render_field form.u_height %}

View File

@@ -0,0 +1,51 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<div class="btn-group pull-right" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=0 %}" class="btn btn-default{% if request.GET.face != '1' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=1 %}" class="btn btn-default{% if request.GET.face == '1' %} active{% endif %}">Rear</a>
</div>
<h1>{% block title %}Rack Elevations{% endblock %}</h1>
<div class="row">
{% if page %}
<div class="col-md-9">
<div style="white-space: nowrap; overflow-x: scroll;">
{% for rack in page %}
<div style="display: inline-block; width: 266px">
<div class="rack_header">
<h4><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></h4>
</div>
{% if face_id %}
{% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 %}
{% else %}
{% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_front_elevation secondary_face=rack.get_rear_elevation face_id=0 %}
{% endif %}
<div class="clearfix"></div>
<div class="rack_header">
<h4><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></h4>
</div>
</div>
{% endfor %}
</div>
<br />
{% include 'inc/paginator.html' %}
</div>
{% else %}
<div class="col-md-9">
<p>No racks found</p>
</div>
{% endif %}
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
{% endblock %}

View File

@@ -1,87 +1,70 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Rack Import{% endblock %}
{% block content %}
<h1>Rack Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Site</td>
<td>Name of the assigned site</td>
<td>DC-4</td>
</tr>
<tr>
<td>Group</td>
<td>Rack group name (optional)</td>
<td>Cage 1400</td>
</tr>
<tr>
<td>Name</td>
<td>Internal rack name</td>
<td>R101</td>
</tr>
<tr>
<td>Facility ID</td>
<td>Rack ID assigned by the facility (optional)</td>
<td>J12.100</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Compute</td>
</tr>
<tr>
<td>Type</td>
<td>Rack type (optional)</td>
<td>4-post cabinet</td>
</tr>
<tr>
<td>Width</td>
<td>Rail-to-rail width (19 or 23 inches)</td>
<td>19</td>
</tr>
<tr>
<td>Height</td>
<td>Height in rack units</td>
<td>42</td>
</tr>
<tr>
<td>Descending units</td>
<td>Units are numbered top-to-bottom</td>
<td>False</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42,False</pre>
</div>
</div>
{% 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>Site</td>
<td>Name of the assigned site</td>
<td>DC-4</td>
</tr>
<tr>
<td>Group</td>
<td>Rack group name (optional)</td>
<td>Cage 1400</td>
</tr>
<tr>
<td>Name</td>
<td>Internal rack name</td>
<td>R101</td>
</tr>
<tr>
<td>Facility ID</td>
<td>Rack ID assigned by the facility (optional)</td>
<td>J12.100</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Role</td>
<td>Functional role (optional)</td>
<td>Compute</td>
</tr>
<tr>
<td>Type</td>
<td>Rack type (optional)</td>
<td>4-post cabinet</td>
</tr>
<tr>
<td>Width</td>
<td>Rail-to-rail width (19 or 23 inches)</td>
<td>19</td>
</tr>
<tr>
<td>Height</td>
<td>Height in rack units</td>
<td>42</td>
</tr>
<tr>
<td>Descending units</td>
<td>Units are numbered top-to-bottom</td>
<td>False</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,Compute,4-post cabinet,19,42,False</pre>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<h1>{% block title %}Rack Reservations{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackreservation_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -1,6 +1,5 @@
{% extends '_base.html' %}
{% load static from staticfiles %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% block title %}{{ site }}{% endblock %}
@@ -224,7 +223,7 @@
<table class="table table-hover panel-body">
{% for rg in rack_groups %}
<tr>
<td><i class="fa fa-fw fa-folder"></i> <a href="{{ rg.get_absolute_url }}">{{ rg.name }}</a></td>
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg.name }}</a></td>
<td>{{ rg.rack_count }}</td>
</tr>
{% endfor %}
@@ -257,7 +256,7 @@
<table class="table table-hover panel-body">
{% for tm in topology_maps %}
<tr>
<td><i class="fa fa-fw fa-map"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
<td><i class="fa fa-fw fa-map-o"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
<td>{{ tm.description }}</td>
</tr>
{% endfor %}

View File

@@ -8,11 +8,17 @@
{% render_field form.name %}
{% render_field form.slug %}
{% render_field form.region %}
{% render_field form.tenant %}
{% render_field form.facility %}
{% render_field form.asn %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Contact Info</strong></div>
<div class="panel-body">

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% block title %}Site Import{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block content %}
{% include 'search_form.html' %}
@@ -48,6 +47,20 @@
</div>
</div>
</div>
{% if perms.secrets %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
</div>
<div class="list-group">
<div class="list-group-item">
<span class="badge pull-right">{{ stats.secret_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
<p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
</div>
</div>
</div>
{% endif %}
</div>
<div class="col-sm-6 col-md-4">
<div class="panel panel-default">
@@ -101,20 +114,25 @@
</div>
</div>
<div class="col-sm-6 col-md-4">
{% if perms.secrets %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
</div>
<div class="list-group">
<div class="list-group-item">
<span class="badge pull-right">{{ stats.secret_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
<p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Global Topology Maps</strong>
</div>
{% endif %}
{% if topology_maps %}
<table class="table table-hover panel-body">
{% for tm in topology_maps %}
<tr>
<td><i class="fa fa-fw fa-map-o"></i> <a href="{% url 'extras-api:topologymap-render' pk=tm.pk %}" target="_blank">{{ tm }}</a></td>
<td>{{ tm.description }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="panel-body text-muted">
None
</div>
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Recent Activity</strong>

View File

@@ -1,13 +1,13 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Import Completed{% endblock %}
{% block content %}
<h1>Import Completed</h1>
{% render_table table %}
<a href="{{ request.path }}" class="btn btn-primary">
<span class="fa fa-download" aria-hidden="true"></span>
Import more
</a>
<h1>{% block title %}Import Completed{% endblock %}</h1>
{% include 'responsive_table.html' %}
<a href="{{ request.path }}" class="btn btn-primary">
<span class="fa fa-download" aria-hidden="true"></span>
Import more
</a>
{% if return_url %}
<a href="{% url return_url %}" class="btn btn-default">View All</a>
{% endif %}
{% endblock %}

View File

@@ -7,12 +7,12 @@
<th></th>
</tr>
{% for attachment in images %}
<tr>
<tr{% if not attachment.size %} class="danger"{% endif %}>
<td>
<i class="fa fa-image"></i>
<a href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
</td>
<td>{{ attachment.image.size|filesizeformat }}</td>
<td>{{ attachment.size|filesizeformat }}</td>
<td>{{ attachment.created }}</td>
<td class="text-right">
{% if perms.extras.change_imageattachment %}

View File

@@ -0,0 +1,28 @@
{% load helpers %}
<div class="paginator pull-right">
{% if paginator.num_pages > 1 %}
<nav>
<ul class="pagination pull-right">
{% if page.has_previous %}
<li><a href="{% querystring request page=page.previous_page_number %}"><i class="fa fa-angle-double-left"></i></a></li>
{% endif %}
{% for p in page.smart_pages %}
{% if p %}
<li{% ifequal page.number p %} class="active"{% endifequal %}><a href="{% querystring request page=p %}">{{ p }}</a></li>
{% else %}
<li class="disabled"><span>&hellip;</span></li>
{% endif %}
{% endfor %}
{% if page.has_next %}
<li><a href="{% querystring request page=page.next_page_number %}"><i class="fa fa-angle-double-right"></i></a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% if page %}
<div class="text-right text-muted">
Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,41 @@
{% load django_tables2 %}
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
{% if table.show_header %}
<thead>
<tr>
{% for column in table.columns %}
{% if column.orderable %}
<th {{ column.attrs.th.as_html }}><a href="{% querystring page=column.order_by_alias.next %}">{{ column.header }}</a></th>
{% else %}
<th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
{% endif %}
<tbody>
{% for row in table.page.object_list|default:table.rows %}
<tr {{ row.attrs.as_html }}>
{% for column, cell in row.items %}
<td {{ column.attrs.td.as_html }}>{{ cell }}</td>
{% endfor %}
</tr>
{% empty %}
{% if table.empty_text %}
<tr>
<td colspan="{{ table.columns|length }}">{{ table.empty_text }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
{% if table.has_footer %}
<tfoot>
<tr>
{% for column in table.columns %}
<td>{{ column.footer }}</td>
{% endfor %}
</tr>
</tfoot>
{% endif %}
</table>

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}Aggregate: {{ aggregate }}{% endblock %}

View File

@@ -1,57 +1,40 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% load form_helpers %}
{% extends 'utilities/obj_import.html' %}
{% block title %}Aggregate Import{% endblock %}
{% block content %}
<h1>Aggregate Import</h1>
<div class="row">
<div class="col-md-6">
<form action="." method="post" class="form">
{% csrf_token %}
{% render_form form %}
<div class="form-group">
<button type="submit" class="btn btn-primary">Submit</button>
<a href="{% url return_url %}" class="btn btn-default">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<h4>CSV Format</h4>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Prefix</td>
<td>IPv4 or IPv6 network</td>
<td>172.16.0.0/12</td>
</tr>
<tr>
<td>RIR</td>
<td>Name of RIR</td>
<td>RFC 1918</td>
</tr>
<tr>
<td>Date Added</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Private IPv4 space</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>172.16.0.0/12,RFC 1918,2016-02-23,Private IPv4 space</pre>
</div>
</div>
{% 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>Prefix</td>
<td>IPv4 or IPv6 network</td>
<td>172.16.0.0/12</td>
</tr>
<tr>
<td>RIR</td>
<td>Name of RIR</td>
<td>RFC 1918</td>
</tr>
<tr>
<td>Date Added</td>
<td>Date in YYYY-MM-DD format (optional)</td>
<td>2016-02-23</td>
</tr>
<tr>
<td>Description</td>
<td>Short description (optional)</td>
<td>Private IPv4 space</td>
</tr>
</tbody>
</table>
<h4>Example</h4>
<pre>172.16.0.0/12,RFC 1918,2016-02-23,Private IPv4 space</pre>
{% endblock %}

View File

@@ -1,5 +1,4 @@
{% extends '_base.html' %}
{% load render_table from django_tables2 %}
{% block title %}{{ ipaddress }}{% endblock %}
@@ -98,14 +97,8 @@
<td>
{% if ipaddress.interface %}
<span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
{% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_remove' pk=ipaddress.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-remove"></i> Remove</a>
{% endif %}
{% else %}
<span class="text-muted">None</span>
{% if perms.dcim.change_device and perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_assign' pk=ipaddress.pk %}" class="btn btn-xs btn-primary"><i class="glyphicon glyphicon-plus"></i> Assign</a>
{% endif %}
{% endif %}
</td>
</tr>
@@ -139,17 +132,11 @@
{% endwith %}
</div>
<div class="col-md-6">
{% with heading='Parent Prefixes' %}
{% render_table parent_prefixes_table 'panel_table.html' %}
{% endwith %}
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
{% if duplicate_ips_table.rows %}
{% with heading='Duplicate IP Addresses' panel_class='danger' %}
{% render_table duplicate_ips_table 'panel_table.html' %}
{% endwith %}
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
{% endif %}
{% with heading='Related IP Addresses' %}
{% render_table related_ips_table 'panel_table.html' %}
{% endwith %}
{% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
</div>
</div>
{% endblock %}

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