Compare commits

...

193 Commits

Author SHA1 Message Date
Jeremy Stretch
f7b0d22f86 Merge pull request #1230 from digitalocean/develop
Release v2.0.4
2017-05-25 14:45:13 -04:00
Jeremy Stretch
5a1877087f Release v2.0.4 2017-05-25 14:42:58 -04:00
Jeremy Stretch
50462ec15d Added notes to discourage the prepending of arbitrary tags to issue titles 2017-05-25 14:38:33 -04:00
Jeremy Stretch
1dd5e2c926 Fixes #1229: Fix validation error on forms where API search is used 2017-05-25 14:33:50 -04:00
Jeremy Stretch
ebddc46bc0 PEP8 fix 2017-05-24 14:22:37 -04:00
Jeremy Stretch
138cbf9761 Created migrations for transition to Unicode literals 2017-05-24 14:18:52 -04:00
Jeremy Stretch
f21c6bca00 Import unicode_literals 2017-05-24 11:33:11 -04:00
Jeremy Stretch
9aad8a7774 Fixes #1219: Fix image attachment URLs when BASE_PATH is set 2017-05-24 10:34:01 -04:00
Jeremy Stretch
68b6c7d886 Fixes #1210: Fix TemplateDoesNotExist errors on browsable API views 2017-05-24 09:40:24 -04:00
Jeremy Stretch
1c489e57cc Added a warning to note "untracked migrations" warnings during an upgrade 2017-05-23 22:36:40 -04:00
Jeremy Stretch
6719578f14 Fixes #1212: Allow assigning new VLANs to global VLAN groups 2017-05-23 22:23:50 -04:00
Jeremy Stretch
d5587de316 Fixes #1213: Corrected table header ordering links 2017-05-23 22:15:13 -04:00
Jeremy Stretch
77f28e3441 Fixes #1214: Add status to list of required fields on child device import form 2017-05-23 22:12:17 -04:00
Jeremy Stretch
3fa63b774e Converted home view to a CBV 2017-05-19 16:03:51 -04:00
Jeremy Stretch
713c7cd8e3 Cleaned up 500 error template 2017-05-19 16:03:04 -04:00
Jeremy Stretch
e6b4d87939 Converted all user views to CBVs 2017-05-19 15:47:19 -04:00
Jeremy Stretch
27c94d9874 Fixes #1206: Fix redirection in admin UI after activating secret keys when BASE_PATH is set 2017-05-19 13:23:17 -04:00
Jeremy Stretch
eece8a0e26 Fixes #1207: Include nested LAG serializer when showing interface connections (API) 2017-05-19 12:59:27 -04:00
Jeremy Stretch
fb85867d72 Converted all object views to class-based views 2017-05-18 17:00:57 -04:00
Jeremy Stretch
c454bfcd84 Fixed incorrect message 2017-05-18 14:53:35 -04:00
Jeremy Stretch
ad95b86fdd Merge pull request #1201 from digitalocean/develop
Release v2.0.3
2017-05-18 14:37:19 -04:00
Jeremy Stretch
769232f368 Post-release version bump 2017-05-18 14:32:11 -04:00
Jeremy Stretch
9cf10eecd1 Release v2.0.3 2017-05-18 14:31:48 -04:00
Jeremy Stretch
f927d5b8f5 Closes #1198: Allow filtering unracked devices on device list 2017-05-18 14:27:07 -04:00
Jeremy Stretch
7fa696dace Fixes #1195: Unable to create an interface connection when searching for peer device 2017-05-18 13:33:26 -04:00
Jeremy Stretch
feac93389c Fixes #1200: Form validation error when connecting power ports to power outlets 2017-05-18 12:11:14 -04:00
Jeremy Stretch
f7969d91b3 Fixes #1199: Bulk import of secrets does not prompt user to generate a session key 2017-05-18 09:17:41 -04:00
Jeremy Stretch
92aafb9043 Added WSGIPassAuthorization to example Apache config 2017-05-17 17:23:08 -04:00
Jeremy Stretch
f9328d53b4 Fixes #1197: Fixed status assignment during bulk import of devices, prefixes, IPs, and VLANs 2017-05-17 17:16:02 -04:00
Jeremy Stretch
f1cbc7da33 Fixes #1157: Hide nav menu search bar on small displays 2017-05-17 16:00:46 -04:00
Jeremy Stretch
01becd21de Closes #1196: Added a lag_id filter to the API interfaces view 2017-05-17 14:43:44 -04:00
Jeremy Stretch
7768b94279 Fixes #1188: Serialize interface LAG as nested objected (API) 2017-05-17 14:32:39 -04:00
Jeremy Stretch
3bc51c8e69 Fixes #1191: Bulk selection of IPs under a prefix incorrect when "select all" is used 2017-05-17 14:23:08 -04:00
Jeremy Stretch
d206be91d5 Fixes #1130: Added zlib1g-dev to Ubuntu/Debian packages list 2017-05-17 12:48:31 -04:00
Jeremy Stretch
6e69c9e375 Restored the option to hide the paginator on panel tables 2017-05-17 12:18:32 -04:00
Jeremy Stretch
f2846af4ec Fixes #1189: Enforce consistent ordering of objects returned by a global search 2017-05-17 12:16:57 -04:00
Jeremy Stretch
657eed1dc9 Merge pull request #1185 from ryanmerolle/patch-1
Added vagrant alternative installation link
2017-05-16 16:53:01 -04:00
Jeremy Stretch
e351ab0171 Fixes #1186: Corrected VLAN edit form so that site assignment is not required 2017-05-16 16:30:28 -04:00
Jeremy Stretch
779446da64 Fixes #1187: Fixed table pagination by introducing a custom table template 2017-05-16 16:19:55 -04:00
ryanmerolle
2ff0d7aa83 Added vagrant alternative installation link 2017-05-16 07:13:05 -04:00
Jeremy Stretch
7ceb64b57b Post-release version bump 2017-05-15 13:24:37 -04:00
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
211 changed files with 4637 additions and 3050 deletions

View File

@@ -45,6 +45,10 @@ sure to include:
* Any error messages generated
* Screenshots (if applicable)
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
The issue will be reviewed by a moderator after submission and the appropriate
labels will be applied.
* Keep in mind that we prioritize bugs based on their severity and how
much work is required to resolve them. It may take some time for someone
to address your issue.
@@ -91,6 +95,10 @@ following:
* Any third-party libraries or other resources which would be
involved
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue title.
The issue will be reviewed by a moderator after submission and the appropriate
labels will be applied.
## Submitting Pull Requests
* Be sure to open an issue before starting work on a pull request, and

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

@@ -58,6 +58,14 @@ This script:
* Applies any database migrations that were included in the release
* Collects all static files to be served by the HTTP service
!!! note
It's possible that the upgrade script will display a notice warning of unreflected database migrations:
Your models have changes that are not yet reflected in a migration, and so won't be applied.
Run 'manage.py makemigrations' to make new migrations, and then re-run 'manage.py migrate' to apply them.
This may occur due to semantic differences in environment, and can be safely ignored. Never attempt to create new migrations unless you are inentionally modifying the database schema.
# Restart the WSGI Service
Finally, restart the WSGI service to run the new code. If you followed this guide for the initial installation, this is done using `supervisorctl`:

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

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import serializers
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
@@ -28,11 +30,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 +84,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

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers
from . import views
@@ -22,4 +24,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

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
from __future__ import unicode_literals
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
from 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 +86,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 +158,18 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Circuit terminations
#
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = forms.ModelChoiceField(
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'site'),
),
required=False,
label='Rack',
widget=APISelect(
@@ -168,8 +177,12 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'site'),
('rack', 'rack'),
),
required=False,
label='Device',
widget=APISelect(
@@ -178,17 +191,13 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device-list',
field_to_update='device'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
interface = ChainedModelChoiceField(
queryset=Interface.objects.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(
@@ -199,8 +208,10 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = CircuitTermination
fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info']
fields = [
'term_side', 'site', 'rack', 'device', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info',
]
help_texts = {
'port_speed': "Physical circuit speed",
'xconnect_id': "ID of the local cross-connect",
@@ -212,49 +223,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

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34
from __future__ import unicode_literals
import dcim.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0008_circuittermination_interface_protect_on_delete'),
]
operations = [
migrations.AlterField(
model_name='circuit',
name='cid',
field=models.CharField(max_length=50, verbose_name='Circuit ID'),
),
migrations.AlterField(
model_name='circuit',
name='commit_rate',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)'),
),
migrations.AlterField(
model_name='circuit',
name='install_date',
field=models.DateField(blank=True, null=True, verbose_name='Date installed'),
),
migrations.AlterField(
model_name='circuittermination',
name='port_speed',
field=models.PositiveIntegerField(verbose_name='Port speed (Kbps)'),
),
migrations.AlterField(
model_name='circuittermination',
name='pp_info',
field=models.CharField(blank=True, max_length=100, verbose_name='Patch panel/port(s)'),
),
migrations.AlterField(
model_name='circuittermination',
name='term_side',
field=models.CharField(choices=[('A', 'A'), ('Z', 'Z')], max_length=1, verbose_name='Termination'),
),
migrations.AlterField(
model_name='circuittermination',
name='upstream_speed',
field=models.PositiveIntegerField(blank=True, help_text='Upstream speed, if different from port speed', null=True, verbose_name='Upstream speed (Kbps)'),
),
migrations.AlterField(
model_name='circuittermination',
name='xconnect_id',
field=models.CharField(blank=True, max_length=50, verbose_name='Cross-connect ID'),
),
migrations.AlterField(
model_name='provider',
name='account',
field=models.CharField(blank=True, max_length=30, verbose_name='Account number'),
),
migrations.AlterField(
model_name='provider',
name='admin_contact',
field=models.TextField(blank=True, verbose_name='Admin contact'),
),
migrations.AlterField(
model_name='provider',
name='asn',
field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'),
),
migrations.AlterField(
model_name='provider',
name='noc_contact',
field=models.TextField(blank=True, verbose_name='NOC contact'),
),
migrations.AlterField(
model_name='provider',
name='portal_url',
field=models.URLField(blank=True, verbose_name='Portal'),
),
]

View File

@@ -1,6 +1,8 @@
from __future__ import unicode_literals
from django.contrib.contenttypes.fields import GenericRelation
from django.core.urlresolvers import reverse
from django.db import models
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from dcim.fields import ASNField
@@ -110,7 +112,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
unique_together = ['provider', 'cid']
def __str__(self):
return u'{} {}'.format(self.provider, self.cid)
return '{} {}'.format(self.provider, self.cid)
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])
@@ -150,10 +152,14 @@ class CircuitTermination(models.Model):
circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True)
interface = models.OneToOneField(
'dcim.Interface', related_name='circuit_termination', blank=True, null=True, on_delete=models.PROTECT
)
port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
help_text='Upstream speed, if different from port speed')
upstream_speed = models.PositiveIntegerField(
blank=True, null=True, verbose_name='Upstream speed (Kbps)',
help_text='Upstream speed, if different from port speed'
)
xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
@@ -162,7 +168,7 @@ class CircuitTermination(models.Model):
unique_together = ['circuit', 'term_side']
def __str__(self):
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
return '{} (Side {})'.format(self.circuit, self.get_term_side_display())
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'

View File

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

View File

@@ -1,8 +1,9 @@
from __future__ import unicode_literals
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import Circuit, CircuitType, Provider
@@ -79,7 +80,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

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

View File

@@ -1,8 +1,11 @@
from __future__ import unicode_literals
from django.conf.urls import url
from . import views
app_name = 'circuits'
urlpatterns = [
# Providers
@@ -11,7 +14,7 @@ urlpatterns = [
url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
url(r'^providers/(?P<slug>[\w-]+)/$', views.provider, name='provider'),
url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
@@ -27,7 +30,7 @@ urlpatterns = [
url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),

View File

@@ -1,17 +1,19 @@
from __future__ import unicode_literals
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.forms import ConfirmationForm
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
@@ -28,18 +30,23 @@ class ProviderListView(ObjectListView):
template_name = 'circuits/provider_list.html'
def provider(request, slug):
class ProviderView(View):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\
.prefetch_related('terminations__site')
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
def get(self, request, slug):
return render(request, 'circuits/provider.html', {
'provider': provider,
'circuits': circuits,
'show_graphs': show_graphs,
})
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related(
'type', 'tenant'
).prefetch_related(
'terminations__site'
)
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
return render(request, 'circuits/provider.html', {
'provider': provider,
'circuits': circuits,
'show_graphs': show_graphs,
})
class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
@@ -95,7 +102,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')
@@ -117,32 +124,33 @@ class CircuitListView(ObjectListView):
template_name = 'circuits/circuit_list.html'
def circuit(request, pk):
class CircuitView(View):
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.select_related(
'site__region', 'interface__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_A
).first()
termination_z = CircuitTermination.objects.select_related(
'site__region', 'interface__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_Z
).first()
def get(self, request, pk):
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
'termination_a': termination_a,
'termination_z': termination_z,
})
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.select_related(
'site__region', 'interface__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_A
).first()
termination_z = CircuitTermination.objects.select_related(
'site__region', 'interface__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_Z
).first()
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
'termination_a': termination_a,
'termination_z': termination_z,
})
class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuit'
model = Circuit
form_class = forms.CircuitForm
fields_initial = ['provider']
template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list'
@@ -230,7 +238,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 +245,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

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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
@@ -66,13 +68,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 +152,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 +265,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 +478,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 +492,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 +583,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,10 +619,12 @@ 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)
lag = NestedInterfaceSerializer()
class Meta:
model = Interface
fields = ['id', 'url', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
fields = ['id', 'url', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
class WritableInterfaceSerializer(serializers.ModelSerializer):

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers
from . import views
@@ -58,4 +60,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

@@ -1,17 +1,19 @@
from __future__ import unicode_literals
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 +325,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

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

View File

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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
import django_filters
from netaddr.core import AddrFormatError
@@ -8,9 +10,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 +150,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 +187,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 +375,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 +391,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 +439,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 +479,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 +593,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,3 +1,5 @@
from __future__ import unicode_literals
from netaddr import EUI, AddrFormatError
from django import forms

View File

@@ -1,6 +1,7 @@
import re
from __future__ import unicode_literals
from mptt.forms import TreeNodeChoiceField
import re
from django import forms
from django.contrib.postgres.forms.array import SimpleArrayField
@@ -9,21 +10,22 @@ 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
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 +83,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 +91,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 +187,25 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
# Racks
#
class RackForm(BootstrapMixin, CustomFieldForm):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}',
))
class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
group = ChainedModelChoiceField(
queryset=RackGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False,
widget=APISelect(
api_url='/api/dcim/rack-groups/?site_id={{site}}',
)
)
comments = CommentField()
class Meta:
model = Rack
fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'comments']
fields = [
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units', 'comments',
]
help_texts = {
'site': "The site at which the rack exists",
'name': "Organizational rack name",
@@ -205,18 +216,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 +271,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 +330,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 +375,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 +490,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,56 +538,89 @@ 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 = []
interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance)
ip_choices += [(ip.id, u'{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
.select_related('nat_inside__interface')
ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
@@ -582,14 +635,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 +655,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 +664,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 +699,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 +762,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 +807,13 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['tenant', 'platform']
def device_status_choices():
status_counts = {}
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
q = forms.CharField(required=False, label='Search')
@@ -763,18 +825,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 +851,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 +960,32 @@ 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(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'site'),
),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'console_server', 'nullable': 'true'}
)
)
console_server = forms.ModelChoiceField(
queryset=Device.objects.all(),
console_server = ChainedModelChoiceField(
queryset=Device.objects.filter(device_type__is_console_server=True),
chains=(
('site', 'site'),
('rack', 'rack'),
),
label='Console Server',
required=False,
widget=APISelect(
@@ -933,11 +1003,14 @@ 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 +1030,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 +1049,32 @@ class ConsoleServerPortCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form):
class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.HiddenInput(),
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'site'),
),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'site'),
('rack', 'rack'),
),
label='Device',
required=False,
widget=APISelect(
@@ -1034,8 +1092,11 @@ 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 +1118,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 +1209,32 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm):
self.cleaned_data['csv'] = connection_list
class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput())
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'site'),
),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'pdu', 'nullable': 'true'}
)
)
pdu = forms.ModelChoiceField(
pdu = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'site'),
('rack', 'rack'),
),
label='PDU',
required=False,
widget=APISelect(
@@ -1201,11 +1252,14 @@ 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 +1279,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 +1298,32 @@ class PowerOutletCreateForm(DeviceComponentForm):
name_pattern = ExpandableNameField(label='Name')
class PowerOutletConnectionForm(BootstrapMixin, forms.Form):
class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
widget=forms.HiddenInput()
)
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
)
)
rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'site'),
),
label='Rack',
required=False,
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'site'),
('rack', 'rack'),
),
label='Device',
required=False,
widget=APISelect(
@@ -1300,8 +1341,11 @@ 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 +1367,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 +1420,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 +1430,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 +1449,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 +1463,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'rack_b'}
)
)
rack_b = forms.ModelChoiceField(
rack_b = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'site_b'),
),
label='Rack',
required=False,
widget=APISelect(
@@ -1444,8 +1475,12 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
attrs={'filter-for': 'device_b', 'nullable': 'true'}
)
)
device_b = forms.ModelChoiceField(
device_b = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'site_b'),
('rack', 'rack_b'),
),
label='Device',
required=False,
widget=APISelect(
@@ -1463,12 +1498,17 @@ 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 +1530,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 +1689,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

@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34
from __future__ import unicode_literals
import dcim.fields
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0036_add_ff_juniper_vcp'),
]
operations = [
migrations.AlterField(
model_name='consoleport',
name='connection_status',
field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True),
),
migrations.AlterField(
model_name='consoleport',
name='cs_port',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name='Console server port'),
),
migrations.AlterField(
model_name='device',
name='asset_tag',
field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name='Asset tag'),
),
migrations.AlterField(
model_name='device',
name='face',
field=models.PositiveSmallIntegerField(blank=True, choices=[[0, 'Front'], [1, 'Rear']], null=True, verbose_name='Rack face'),
),
migrations.AlterField(
model_name='device',
name='position',
field=models.PositiveSmallIntegerField(blank=True, help_text='The lowest-numbered unit occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Position (U)'),
),
migrations.AlterField(
model_name='device',
name='primary_ip4',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip4_for', to='ipam.IPAddress', verbose_name='Primary IPv4'),
),
migrations.AlterField(
model_name='device',
name='primary_ip6',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_ip6_for', to='ipam.IPAddress', verbose_name='Primary IPv6'),
),
migrations.AlterField(
model_name='device',
name='serial',
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
),
migrations.AlterField(
model_name='device',
name='status',
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [0, 'Offline'], [2, 'Planned'], [3, 'Staged'], [4, 'Failed'], [5, 'Inventory']], default=1, verbose_name='Status'),
),
migrations.AlterField(
model_name='devicebay',
name='name',
field=models.CharField(max_length=50, verbose_name='Name'),
),
migrations.AlterField(
model_name='devicetype',
name='interface_ordering',
field=models.PositiveSmallIntegerField(choices=[[1, 'Slot/position'], [2, 'Name (alphabetically)']], default=1),
),
migrations.AlterField(
model_name='devicetype',
name='is_console_server',
field=models.BooleanField(default=False, help_text='This type of device has console server ports', verbose_name='Is a console server'),
),
migrations.AlterField(
model_name='devicetype',
name='is_full_depth',
field=models.BooleanField(default=True, help_text='Device consumes both front and rear rack faces', verbose_name='Is full depth'),
),
migrations.AlterField(
model_name='devicetype',
name='is_network_device',
field=models.BooleanField(default=True, help_text='This type of device has network interfaces', verbose_name='Is a network device'),
),
migrations.AlterField(
model_name='devicetype',
name='is_pdu',
field=models.BooleanField(default=False, help_text='This type of device has power outlets', verbose_name='Is a PDU'),
),
migrations.AlterField(
model_name='devicetype',
name='part_number',
field=models.CharField(blank=True, help_text='Discrete part number (optional)', max_length=50),
),
migrations.AlterField(
model_name='devicetype',
name='subdevice_role',
field=models.NullBooleanField(choices=[(None, 'None'), (True, 'Parent'), (False, 'Child')], default=None, help_text='Parent devices house child devices in device bays. Select "None" if this device type is neither a parent nor a child.', verbose_name='Parent/child status'),
),
migrations.AlterField(
model_name='devicetype',
name='u_height',
field=models.PositiveSmallIntegerField(default=1, verbose_name='Height (U)'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interface',
name='lag',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='member_interfaces', to='dcim.Interface', verbose_name='Parent LAG'),
),
migrations.AlterField(
model_name='interface',
name='mac_address',
field=dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address'),
),
migrations.AlterField(
model_name='interface',
name='mgmt_only',
field=models.BooleanField(default=False, help_text='This interface is used only for out-of-band management', verbose_name='OOB Management'),
),
migrations.AlterField(
model_name='interfaceconnection',
name='connection_status',
field=models.BooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True, verbose_name='Status'),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='mgmt_only',
field=models.BooleanField(default=False, verbose_name='Management only'),
),
migrations.AlterField(
model_name='inventoryitem',
name='discovered',
field=models.BooleanField(default=False, verbose_name='Discovered'),
),
migrations.AlterField(
model_name='inventoryitem',
name='name',
field=models.CharField(max_length=50, verbose_name='Name'),
),
migrations.AlterField(
model_name='inventoryitem',
name='part_id',
field=models.CharField(blank=True, max_length=50, verbose_name='Part ID'),
),
migrations.AlterField(
model_name='inventoryitem',
name='serial',
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
),
migrations.AlterField(
model_name='platform',
name='rpc_client',
field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='RPC client'),
),
migrations.AlterField(
model_name='powerport',
name='connection_status',
field=models.NullBooleanField(choices=[[False, 'Planned'], [True, 'Connected']], default=True),
),
migrations.AlterField(
model_name='rack',
name='desc_units',
field=models.BooleanField(default=False, help_text='Units are numbered top-to-bottom', verbose_name='Descending units'),
),
migrations.AlterField(
model_name='rack',
name='facility_id',
field=utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name='Facility ID'),
),
migrations.AlterField(
model_name='rack',
name='type',
field=models.PositiveSmallIntegerField(blank=True, choices=[(100, '2-post frame'), (200, '4-post frame'), (300, '4-post cabinet'), (1000, 'Wall-mounted frame'), (1100, 'Wall-mounted cabinet')], null=True, verbose_name='Type'),
),
migrations.AlterField(
model_name='rack',
name='u_height',
field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name='Height (U)'),
),
migrations.AlterField(
model_name='rack',
name='width',
field=models.PositiveSmallIntegerField(choices=[(19, '19 inches'), (23, '23 inches')], default=19, help_text='Rail-to-rail width', verbose_name='Width'),
),
migrations.AlterField(
model_name='site',
name='asn',
field=dcim.fields.ASNField(blank=True, null=True, verbose_name='ASN'),
),
migrations.AlterField(
model_name='site',
name='contact_email',
field=models.EmailField(blank=True, max_length=254, verbose_name='Contact E-mail'),
),
]

View File

@@ -1,4 +1,6 @@
from __future__ import unicode_literals
from collections import OrderedDict
from itertools import count, groupby
from mptt.models import MPTTModel, TreeForeignKey
@@ -8,10 +10,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
@@ -22,7 +24,6 @@ from utilities.fields import ColorField, NullableCharField
from utilities.managers import NaturalOrderByManager
from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .fields import ASNField, MACAddressField
@@ -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']
@@ -324,7 +346,7 @@ class RackGroup(models.Model):
]
def __str__(self):
return u'{} - {}'.format(self.site.name, self.name)
return '{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
@@ -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])
@@ -444,8 +466,10 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
@property
def display_name(self):
if self.facility_id:
return u"{} ({})".format(self.name, self.facility_id)
return self.name
return "{} ({})".format(self.name, self.facility_id)
elif self.name:
return self.name
return ""
def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
"""
@@ -545,7 +569,7 @@ class RackReservation(models.Model):
ordering = ['created']
def __str__(self):
return u"Reservation for rack {}".format(self.rack)
return "Reservation for rack {}".format(self.rack)
def clean(self):
@@ -555,7 +579,7 @@ class RackReservation(models.Model):
invalid_units = [u for u in self.units if u not in self.rack.units]
if invalid_units:
raise ValidationError({
'units': u"Invalid unit(s) for {}U rack: {}".format(
'units': "Invalid unit(s) for {}U rack: {}".format(
self.rack.u_height,
', '.join([str(u) for u in invalid_units]),
),
@@ -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
@@ -700,7 +733,7 @@ class DeviceType(models.Model, CustomFieldModel):
@property
def full_name(self):
return u'{} {}'.format(self.manufacturer.name, self.model)
return '{} {}'.format(self.manufacturer.name, self.model)
@property
def is_parent_device(self):
@@ -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 "{}".format(self.device_type)
return ""
@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.
@@ -1276,7 +1320,7 @@ class Interface(models.Model):
# An interface's LAG must belong to the same device
if self.lag and self.lag.device != self.device:
raise ValidationError({
'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format(
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
self.lag.name, self.lag.device.name
)
})
@@ -1284,14 +1328,14 @@ class Interface(models.Model):
# A virtual interface cannot have a parent LAG
if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None:
raise ValidationError({
'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
})
# Only a LAG can have LAG members
if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
raise ValidationError({
'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format(
u", ".join([iface.name for iface in self.member_interfaces.all()])
", ".join([iface.name for iface in self.member_interfaces.all()])
)
})
@@ -1384,7 +1428,7 @@ class DeviceBay(models.Model):
unique_together = ['device', 'name']
def __str__(self):
return u'{} - {}'.format(self.device.name, self.name)
return '{} - {}'.format(self.device.name, self.name)
def clean(self):

View File

@@ -1,12 +1,13 @@
from __future__ import unicode_literals
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, 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 +65,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 +93,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 +250,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 +429,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 +449,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 +466,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 +476,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

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

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
from __future__ import unicode_literals
from django.conf.urls import url
from extras.views import ImageAttachmentEditView
from ipam.views import ServiceEditView
from secrets.views import secret_add
from extras.views import ImageAttachmentEditView
from .models import Device, Rack, Site
from . import views
app_name = 'dcim'
urlpatterns = [
# Regions
@@ -21,7 +23,7 @@ urlpatterns = [
url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
@@ -39,16 +41,19 @@ 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'),
url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
url(r'^racks/(?P<pk>\d+)/$', views.rack, name='rack'),
url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
@@ -65,7 +70,7 @@ urlpatterns = [
url(r'^device-types/add/$', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
url(r'^device-types/(?P<pk>\d+)/$', views.devicetype, name='devicetype'),
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
@@ -113,12 +118,11 @@ urlpatterns = [
url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'),
url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
url(r'^devices/(?P<pk>\d+)/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+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<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}),

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from __future__ import unicode_literals
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from extras.models import CF_TYPE_SELECT, CustomField, CustomFieldChoice, CustomFieldValue
@@ -14,12 +18,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("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("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("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 +66,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

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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers
from . import views
@@ -29,4 +31,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

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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
import django_filters
from django.contrib.auth.models import User

View File

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

View File

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

View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34
from __future__ import unicode_literals
from django.db import migrations, models
import extras.models
class Migration(migrations.Migration):
dependencies = [
('extras', '0006_add_imageattachments'),
]
operations = [
migrations.AlterField(
model_name='customfield',
name='default',
field=models.CharField(blank=True, help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.', max_length=100),
),
migrations.AlterField(
model_name='customfield',
name='is_filterable',
field=models.BooleanField(default=True, help_text='This field can be used to filter objects.'),
),
migrations.AlterField(
model_name='customfield',
name='label',
field=models.CharField(blank=True, help_text="Name of the field as displayed to users (if not provided, the field's name will be used)", max_length=50),
),
migrations.AlterField(
model_name='customfield',
name='obj_type',
field=models.ManyToManyField(help_text='The object(s) to which this field applies.', related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'),
),
migrations.AlterField(
model_name='customfield',
name='required',
field=models.BooleanField(default=False, help_text='Determines whether this field is required when creating new objects or editing an existing object.'),
),
migrations.AlterField(
model_name='customfield',
name='type',
field=models.PositiveSmallIntegerField(choices=[(100, 'Text'), (200, 'Integer'), (300, 'Boolean (true/false)'), (400, 'Date'), (500, 'URL'), (600, 'Selection')], default=100),
),
migrations.AlterField(
model_name='customfield',
name='weight',
field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form'),
),
migrations.AlterField(
model_name='customfieldchoice',
name='weight',
field=models.PositiveSmallIntegerField(default=100, help_text='Higher weights appear lower in the list'),
),
migrations.AlterField(
model_name='graph',
name='link',
field=models.URLField(blank=True, verbose_name='Link URL'),
),
migrations.AlterField(
model_name='graph',
name='name',
field=models.CharField(max_length=100, verbose_name='Name'),
),
migrations.AlterField(
model_name='graph',
name='source',
field=models.CharField(max_length=500, verbose_name='Source URL'),
),
migrations.AlterField(
model_name='graph',
name='type',
field=models.PositiveSmallIntegerField(choices=[(100, 'Interface'), (200, 'Provider'), (300, 'Site')]),
),
migrations.AlterField(
model_name='imageattachment',
name='image',
field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'),
),
migrations.AlterField(
model_name='topologymap',
name='device_patterns',
field=models.TextField(help_text='Identify devices to include in the diagram using regular expressions, one per line. Each line will result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. Devices will be rendered in the order they are defined.'),
),
migrations.AlterField(
model_name='useraction',
name='action',
field=models.PositiveSmallIntegerField(choices=[(1, 'created'), (7, 'bulk created'), (2, 'imported'), (3, 'modified'), (4, 'bulk edited'), (5, 'deleted'), (6, 'bulk deleted')]),
),
]

View File

@@ -1,3 +1,4 @@
from __future__ import unicode_literals
from collections import OrderedDict
from datetime import date
import graphviz
@@ -13,6 +14,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 +159,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')
@@ -176,7 +176,7 @@ class CustomFieldValue(models.Model):
unique_together = ['field', 'obj_type', 'obj_id']
def __str__(self):
return u'{} {}'.format(self.obj, self.field)
return '{} {}'.format(self.obj, self.field)
@property
def value(self):
@@ -254,7 +254,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()
@@ -268,7 +270,7 @@ class ExportTemplate(models.Model):
]
def __str__(self):
return u'{}: {}'.format(self.content_type, self.name)
return '{}: {}'.format(self.content_type, self.name)
def to_response(self, context_dict, filename):
"""
@@ -294,7 +296,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 +318,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 +337,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 +355,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)
@@ -384,7 +396,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 +427,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
@@ -482,8 +504,8 @@ class UserAction(models.Model):
def __str__(self):
if self.message:
return u'{} {}'.format(self.user, self.message)
return u'{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
return '{} {}'.format(self.user, self.message)
return '{} {} {}'.format(self.user, self.get_action_display(), self.content_type)
def icon(self):
if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]:

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
from __future__ import unicode_literals
from django.conf.urls import url
from extras import views
app_name = 'extras'
urlpatterns = [
# Image attachments

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import get_object_or_404
@@ -18,7 +20,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 +28,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

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
@@ -31,11 +33,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 +98,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 +171,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 +218,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 +257,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

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers
from . import views
@@ -37,4 +39,5 @@ router.register(r'vlans', views.VLANViewSet)
# Services
router.register(r'services', views.ServiceViewSet)
app_name = 'ipam-api'
urlpatterns = router.urls

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
import django_filters
from netaddr import IPNetwork
from netaddr.core import AddrFormatError
@@ -8,8 +10,10 @@ from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from .models import (
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
VLAN_STATUS_CHOICES, VLANGroup, VRF,
)
class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
@@ -153,10 +157,13 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=PREFIX_STATUS_CHOICES
)
class Meta:
model = Prefix
fields = ['family', 'status']
fields = ['family']
def search(self, queryset, name, value):
if not value.strip():
@@ -237,10 +244,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 +347,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,3 +1,5 @@
from __future__ import unicode_literals
from netaddr import IPNetwork, AddrFormatError
from django import forms

View File

@@ -1,14 +1,17 @@
from __future__ import unicode_literals
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 (
Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN,
VLANGroup, VLAN_STATUS_CHOICES, VRF,
@@ -32,11 +35,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 +64,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 +166,36 @@ 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 +206,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 +224,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 +269,7 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=add_blank_choice(PREFIX_STATUS_CHOICES), required=False)
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False)
is_pool = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Is a pool')
description = forms.CharField(max_length=100, required=False)
class Meta:
@@ -262,7 +280,7 @@ def prefix_status_choices():
status_counts = {}
for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -302,131 +320,197 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
# IP addresses
#
class IPAddressForm(BootstrapMixin, CustomFieldForm):
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'}))
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress-list', field_to_update='nat_inside', obj_label='address')
)
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'tenant', 'status', 'nat_inside', 'description']
widgets = {
'nat_inside': APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', display_field='address')
}
def __init__(self, *args, **kwargs):
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
if self.instance.nat_inside:
nat_inside = self.instance.nat_inside
# If the IP is assigned to an interface, populate site/device fields accordingly
if self.instance.nat_inside.interface:
self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
self.fields['nat_device'].queryset = Device.objects.filter(
site=nat_inside.interface.device.site
)
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device=nat_inside.interface.device
)
else:
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
else:
# Initialize nat_device choices if nat_site is set
if self.is_bound and self.data.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(site__pk=self.data['nat_site'])
elif self.initial.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(site=self.initial['nat_site'])
else:
self.fields['nat_device'].choices = []
# Initialize nat_inside choices if nat_device is set
if self.is_bound and self.data.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device__pk=self.data['nat_device'])
elif self.initial.get('nat_device'):
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device__pk=self.initial['nat_device'])
else:
self.fields['nat_inside'].choices = []
class IPAddressBulkAddForm(BootstrapMixin, forms.Form):
address = ExpandableIPAddressField()
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', empty_label='Global')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES)
description = forms.CharField(max_length=100, required=False)
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
interface_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
required=False,
widget=forms.Select(
attrs={'filter-for': 'rack'}
attrs={'filter-for': 'interface_rack'}
)
)
rack = forms.ModelChoiceField(
interface_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
chains=(
('site', 'interface_site'),
),
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
api_url='/api/dcim/racks/?site_id={{interface_site}}',
display_field='display_name',
attrs={'filter-for': 'device', 'nullable': 'true'}
attrs={'filter-for': 'interface_device', 'nullable': 'true'}
)
)
device = forms.ModelChoiceField(
interface_device = ChainedModelChoiceField(
queryset=Device.objects.all(),
label='Device',
chains=(
('site', 'interface_site'),
('rack', 'interface_rack'),
),
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
interface = ChainedModelChoiceField(
queryset=Interface.objects.all(),
chains=(
('device', 'interface_device'),
),
required=False,
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
)
)
nat_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
widget=forms.Select(
attrs={'filter-for': 'nat_rack'}
)
)
nat_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'nat_site'),
),
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_device', 'nullable': 'true'}
)
)
nat_device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'nat_site'),
('rack', 'nat_rack'),
),
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{nat_site}}&rack_id={{nat_rack}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'}
)
)
nat_inside = ChainedModelChoiceField(
queryset=IPAddress.objects.all(),
chains=(
('interface__device', 'nat_device'),
),
required=False,
label='IP Address',
widget=APISelect(
api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
display_field='address'
)
)
livesearch = forms.CharField(
required=False,
label='Search',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device-list',
field_to_update='device'
query_url='ipam-api:ipaddress-list',
field_to_update='nat_inside',
obj_label='address'
)
)
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
label='Interface',
widget=APISelect(
api_url='/api/dcim/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_site', 'nat_rack',
'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 +518,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 +526,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 +549,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']:
@@ -503,7 +591,7 @@ def ipaddress_status_choices():
status_counts = {}
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -552,14 +640,29 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
# VLANs
#
class VLANForm(BootstrapMixin, CustomFieldForm):
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
))
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=forms.Select(
attrs={'filter-for': 'group', 'nullable': 'true'}
)
)
group = ChainedModelChoiceField(
queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False,
label='Group',
widget=APISelect(
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
)
)
class Meta:
model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
fields = ['site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant']
help_texts = {
'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)",
@@ -568,45 +671,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,14 +739,14 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'description']
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
def vlan_status_choices():
status_counts = {}
for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
status_counts[status['status']] = status['count']
return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):

View File

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

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import ipam.fields
class Migration(migrations.Migration):
dependencies = [
('ipam', '0015_global_vlans'),
]
operations = [
migrations.AlterField(
model_name='aggregate',
name='family',
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')]),
),
migrations.AlterField(
model_name='aggregate',
name='rir',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name='RIR'),
),
migrations.AlterField(
model_name='ipaddress',
name='address',
field=ipam.fields.IPAddressField(help_text='IPv4 or IPv6 address (with mask)'),
),
migrations.AlterField(
model_name='ipaddress',
name='family',
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False),
),
migrations.AlterField(
model_name='ipaddress',
name='nat_inside',
field=models.OneToOneField(blank=True, help_text='The IP for which this address is the "outside" IP', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name='NAT (Inside)'),
),
migrations.AlterField(
model_name='ipaddress',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, verbose_name='Status'),
),
migrations.AlterField(
model_name='ipaddress',
name='vrf',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name='VRF'),
),
migrations.AlterField(
model_name='prefix',
name='family',
field=models.PositiveSmallIntegerField(choices=[(4, 'IPv4'), (6, 'IPv6')], editable=False),
),
migrations.AlterField(
model_name='prefix',
name='is_pool',
field=models.BooleanField(default=False, help_text='All IP addresses within this prefix are considered usable', verbose_name='Is a pool'),
),
migrations.AlterField(
model_name='prefix',
name='prefix',
field=ipam.fields.IPNetworkField(help_text='IPv4 or IPv6 network with mask'),
),
migrations.AlterField(
model_name='prefix',
name='role',
field=models.ForeignKey(blank=True, help_text='The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'),
),
migrations.AlterField(
model_name='prefix',
name='status',
field=models.PositiveSmallIntegerField(choices=[(0, 'Container'), (1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, help_text='Operational status of this prefix', verbose_name='Status'),
),
migrations.AlterField(
model_name='prefix',
name='vlan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name='VLAN'),
),
migrations.AlterField(
model_name='prefix',
name='vrf',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name='VRF'),
),
migrations.AlterField(
model_name='rir',
name='is_private',
field=models.BooleanField(default=False, help_text='IP space managed by this RIR is considered private', verbose_name='Private'),
),
migrations.AlterField(
model_name='service',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
),
migrations.AlterField(
model_name='service',
name='ipaddresses',
field=models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name='IP addresses'),
),
migrations.AlterField(
model_name='service',
name='port',
field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name='Port number'),
),
migrations.AlterField(
model_name='service',
name='protocol',
field=models.PositiveSmallIntegerField(choices=[(6, 'TCP'), (17, 'UDP')]),
),
migrations.AlterField(
model_name='vlan',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated')], default=1, verbose_name='Status'),
),
migrations.AlterField(
model_name='vlan',
name='vid',
field=models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name='ID'),
),
migrations.AlterField(
model_name='vrf',
name='enforce_unique',
field=models.BooleanField(default=True, help_text='Prevent duplicate prefixes/IP addresses within this VRF', verbose_name='Enforce unique space'),
),
migrations.AlterField(
model_name='vrf',
name='rd',
field=models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher'),
),
]

View File

@@ -1,12 +1,14 @@
from __future__ import unicode_literals
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
@@ -15,7 +17,6 @@ from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from utilities.utils import csv_format
from .fields import IPNetworkField, IPAddressField
@@ -499,7 +500,7 @@ class VLANGroup(models.Model):
def __str__(self):
if self.site is None:
return self.name
return u'{} - {}'.format(self.site.name, self.name)
return '{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@@ -538,7 +539,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 +566,9 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@property
def display_name(self):
return u'{} ({})'.format(self.vid, self.name)
if self.vid and self.name:
return "{} ({})".format(self.vid, self.name)
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
@@ -591,4 +594,4 @@ class Service(CreatedUpdatedModel):
unique_together = ['device', 'protocol', 'port']
def __str__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())

View File

@@ -1,8 +1,9 @@
from __future__ import unicode_literals
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
@@ -70,9 +71,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 +291,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

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

View File

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

View File

@@ -1,8 +1,11 @@
from __future__ import unicode_literals
from django.conf.urls import url
from . import views
app_name = 'ipam'
urlpatterns = [
# VRFs
@@ -11,7 +14,7 @@ urlpatterns = [
url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'),
url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
url(r'^vrfs/(?P<pk>\d+)/$', views.vrf, name='vrf'),
url(r'^vrfs/(?P<pk>\d+)/$', views.VRFView.as_view(), name='vrf'),
url(r'^vrfs/(?P<pk>\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'),
url(r'^vrfs/(?P<pk>\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'),
@@ -27,7 +30,7 @@ urlpatterns = [
url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
url(r'^aggregates/(?P<pk>\d+)/$', views.aggregate, name='aggregate'),
url(r'^aggregates/(?P<pk>\d+)/$', views.AggregateView.as_view(), name='aggregate'),
url(r'^aggregates/(?P<pk>\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'),
url(r'^aggregates/(?P<pk>\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
@@ -43,10 +46,10 @@ urlpatterns = [
url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'),
url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
url(r'^prefixes/(?P<pk>\d+)/$', views.prefix, name='prefix'),
url(r'^prefixes/(?P<pk>\d+)/$', views.PrefixView.as_view(), name='prefix'),
url(r'^prefixes/(?P<pk>\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'),
url(r'^prefixes/(?P<pk>\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'),
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.prefix_ipaddresses, name='prefix_ipaddresses'),
url(r'^prefixes/(?P<pk>\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
# IP addresses
url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'),
@@ -55,10 +58,8 @@ urlpatterns = [
url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.ipaddress, name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/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
@@ -73,7 +74,7 @@ urlpatterns = [
url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'),
url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
url(r'^vlans/(?P<pk>\d+)/$', views.vlan, name='vlan'),
url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),

View File

@@ -1,21 +1,20 @@
from __future__ import unicode_literals
from django_tables2 import RequestConfig
import netaddr
from django.conf import settings
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views.generic import View
from dcim.models import Device
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import (
Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role,
@@ -99,18 +98,20 @@ class VRFListView(ObjectListView):
template_name = 'ipam/vrf_list.html'
def vrf(request, pk):
class VRFView(View):
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefix_table = tables.PrefixBriefTable(
list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
)
prefix_table.exclude = ('vrf',)
def get(self, request, pk):
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
'prefix_table': prefix_table,
})
vrf = get_object_or_404(VRF.objects.all(), pk=pk)
prefix_table = tables.PrefixBriefTable(
list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role'))
)
prefix_table.exclude = ('vrf',)
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
'prefix_table': prefix_table,
})
class VRFEditView(PermissionRequiredMixin, ObjectEditView):
@@ -244,7 +245,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')
@@ -284,37 +285,44 @@ class AggregateListView(ObjectListView):
}
def aggregate(request, pk):
class AggregateView(View):
aggregate = get_object_or_404(Aggregate, pk=pk)
def get(self, request, pk):
# Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))\
.select_related('site', 'role').annotate_depth(limit=0)
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
aggregate = get_object_or_404(Aggregate, pk=pk)
prefix_table = tables.PrefixTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.base_columns['pk'].visible = True
# Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.filter(
prefix__net_contained_or_equal=str(aggregate.prefix)
).select_related(
'site', 'role'
).annotate_depth(
limit=0
)
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(prefix_table)
prefix_table = tables.PrefixTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.base_columns['pk'].visible = True
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(prefix_table)
return render(request, 'ipam/aggregate.html', {
'aggregate': aggregate,
'prefix_table': prefix_table,
'permissions': permissions,
})
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return render(request, 'ipam/aggregate.html', {
'aggregate': aggregate,
'prefix_table': prefix_table,
'permissions': permissions,
})
class AggregateEditView(PermissionRequiredMixin, ObjectEditView):
@@ -370,7 +378,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')
@@ -397,66 +405,120 @@ class PrefixListView(ObjectListView):
return self.queryset.annotate_depth(limit=limit)
def prefix(request, pk):
class PrefixView(View):
prefix = get_object_or_404(Prefix.objects.select_related(
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
), pk=pk)
def get(self, request, pk):
try:
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
except Aggregate.DoesNotExist:
aggregate = None
prefix = get_object_or_404(Prefix.objects.select_related(
'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
), pk=pk)
# Count child IP addresses
ipaddress_count = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\
.count()
try:
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
except Aggregate.DoesNotExist:
aggregate = None
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(Q(vrf=prefix.vrf) | Q(vrf__isnull=True))\
.filter(prefix__net_contains=str(prefix.prefix))\
.select_related('site', 'role').annotate_depth()
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefix_table.exclude = ('vrf',)
# Count child IP addresses
ipaddress_count = IPAddress.objects.filter(
vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix)
).count()
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\
.select_related('site', 'role')
duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
duplicate_prefix_table.exclude = ('vrf',)
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(
Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
).filter(
prefix__net_contains=str(prefix.prefix)
).select_related(
'site', 'role'
).annotate_depth()
parent_prefix_table = tables.PrefixBriefTable(parent_prefixes)
parent_prefix_table.exclude = ('vrf',)
# Child prefixes table
child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\
.select_related('site', 'role').annotate_depth(limit=0)
if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table.base_columns['pk'].visible = True
# Duplicate prefixes table
duplicate_prefixes = Prefix.objects.filter(
vrf=prefix.vrf, prefix=str(prefix.prefix)
).exclude(
pk=prefix.pk
).select_related(
'site', 'role'
)
duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes))
duplicate_prefix_table.exclude = ('vrf',)
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(child_prefix_table)
# Child prefixes table
child_prefixes = Prefix.objects.filter(
vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
).select_related(
'site', 'role'
).annotate_depth(limit=0)
if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table.base_columns['pk'].visible = True
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(child_prefix_table)
return render(request, 'ipam/prefix.html', {
'prefix': prefix,
'aggregate': aggregate,
'ipaddress_count': ipaddress_count,
'parent_prefix_table': parent_prefix_table,
'child_prefix_table': child_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table,
'permissions': permissions,
'return_url': prefix.get_absolute_url(),
})
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_prefix'),
'change': request.user.has_perm('ipam.change_prefix'),
'delete': request.user.has_perm('ipam.delete_prefix'),
}
return render(request, 'ipam/prefix.html', {
'prefix': prefix,
'aggregate': aggregate,
'ipaddress_count': ipaddress_count,
'parent_prefix_table': parent_prefix_table,
'child_prefix_table': child_prefix_table,
'duplicate_prefix_table': duplicate_prefix_table,
'permissions': permissions,
'return_url': prefix.get_absolute_url(),
})
class PrefixIPAddressesView(View):
def get(self, request, pk):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
# Find all IPAddresses belonging to this Prefix
ipaddresses = IPAddress.objects.filter(
vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix)
).select_related(
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
)
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
ip_table = tables.IPAddressTable(ipaddresses)
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table.base_columns['pk'].visible = True
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(ip_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_ipaddress'),
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return render(request, 'ipam/prefix_ipaddresses.html', {
'prefix': prefix,
'ip_table': ip_table,
'permissions': permissions,
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix),
})
class PrefixEditView(PermissionRequiredMixin, ObjectEditView):
@@ -464,7 +526,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'
@@ -499,153 +560,65 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
default_return_url = 'ipam:prefix_list'
def prefix_ipaddresses(request, pk):
prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
# Find all IPAddresses belonging to this Prefix
ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_host_contained=str(prefix.prefix))\
.select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for')
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
ip_table = tables.IPAddressTable(ipaddresses)
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
ip_table.base_columns['pk'].visible = True
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(ip_table)
# Compile permissions list for rendering the object table
permissions = {
'add': request.user.has_perm('ipam.add_ipaddress'),
'change': request.user.has_perm('ipam.change_ipaddress'),
'delete': request.user.has_perm('ipam.delete_ipaddress'),
}
return render(request, 'ipam/prefix_ipaddresses.html', {
'prefix': prefix,
'ip_table': ip_table,
'permissions': permissions,
})
#
# IP addresses
#
class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm
table = tables.IPAddressTable
template_name = 'ipam/ipaddress_list.html'
def ipaddress(request, pk):
class IPAddressView(View):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
def get(self, request, pk):
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip))\
.select_related('site', 'role')
parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
parent_prefixes_table.exclude = ('vrf',)
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
# Duplicate IPs table
duplicate_ips = IPAddress.objects.filter(vrf=ipaddress.vrf, address=str(ipaddress.address))\
.exclude(pk=ipaddress.pk).select_related('interface__device', 'nat_inside')
duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(
vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
).select_related(
'site', 'role'
)
parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes))
parent_prefixes_table.exclude = ('vrf',)
# Related IP table
related_ips = IPAddress.objects.select_related('interface__device').exclude(address=str(ipaddress.address))\
.filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address))
related_ips_table = tables.IPAddressBriefTable(list(related_ips))
# Duplicate IPs table
duplicate_ips = IPAddress.objects.filter(
vrf=ipaddress.vrf, address=str(ipaddress.address)
).exclude(
pk=ipaddress.pk
).select_related(
'interface__device', 'nat_inside'
)
duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips))
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
'related_ips_table': related_ips_table,
})
# Related IP table
related_ips = IPAddress.objects.select_related(
'interface__device'
).exclude(
address=str(ipaddress.address)
).filter(
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
)
related_ips_table = tables.IPAddressBriefTable(list(related_ips))
@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}),
})
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
'related_ips_table': related_ips_table,
})
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 +631,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 +692,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')
@@ -741,17 +715,21 @@ class VLANListView(ObjectListView):
template_name = 'ipam/vlan_list.html'
def vlan(request, pk):
class VLANView(View):
vlan = get_object_or_404(VLAN.objects.select_related('site__region', 'tenant__group', 'role'), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
prefix_table = tables.PrefixBriefTable(list(prefixes))
prefix_table.exclude = ('vlan',)
def get(self, request, pk):
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
'prefix_table': prefix_table,
})
vlan = get_object_or_404(VLAN.objects.select_related(
'site__region', 'tenant__group', 'role'
), pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role')
prefix_table = tables.PrefixBriefTable(list(prefixes))
prefix_table.exclude = ('vlan',)
return render(request, 'ipam/vlan.html', {
'vlan': vlan,
'prefix_table': prefix_table,
})
class VLANEditView(PermissionRequiredMixin, ObjectEditView):
@@ -807,7 +785,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

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

View File

@@ -13,7 +13,7 @@ except ImportError:
)
VERSION = '2.0-beta2'
VERSION = '2.0.4'
# Import local configuration
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
@@ -112,6 +112,7 @@ INSTALLED_APPS = (
'django.contrib.humanize',
'corsheaders',
'debug_toolbar',
'django_filters',
'django_tables2',
'mptt',
'rest_framework',
@@ -180,8 +181,8 @@ STATICFILES_DIRS = (
)
# Media
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/{}media/'.format(BASE_PATH)
# Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
DATA_UPLOAD_MAX_NUMBER_FIELDS = None

View File

@@ -1,42 +1,47 @@
from __future__ import unicode_literals
from rest_framework_swagger.views import get_swagger_view
from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from django.views.static import serve
from netbox.views import APIRootView, home, handle_500, SearchView, trigger_500
from users.views import login, logout
from netbox.views import APIRootView, handle_500, HomeView, SearchView, trigger_500
from users.views import LoginView, LogoutView
handler500 = handle_500
swagger_view = get_swagger_view(title='NetBox API')
_patterns = [
# Base views
url(r'^$', home, name='home'),
url(r'^$', HomeView.as_view(), name='home'),
url(r'^search/$', SearchView.as_view(), name='search'),
# Login/logout
url(r'^login/$', login, name='login'),
url(r'^logout/$', logout, name='logout'),
url(r'^login/$', LoginView.as_view(), name='login'),
url(r'^logout/$', LogoutView.as_view(), name='logout'),
# Apps
url(r'^circuits/', include('circuits.urls', namespace='circuits')),
url(r'^dcim/', include('dcim.urls', namespace='dcim')),
url(r'^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 +50,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,5 @@
from __future__ import unicode_literals
from collections import OrderedDict
import sys
from rest_framework.views import APIView
@@ -13,7 +15,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,129 +29,133 @@ 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):
class HomeView(View):
template_name = 'home.html'
stats = {
def get(self, request):
# Organization
'site_count': Site.objects.count(),
'tenant_count': Tenant.objects.count(),
stats = {
# DCIM
'rack_count': Rack.objects.count(),
'device_count': Device.objects.count(),
'interface_connections_count': InterfaceConnection.objects.count(),
'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(),
'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
# Organization
'site_count': Site.objects.count(),
'tenant_count': Tenant.objects.count(),
# IPAM
'vrf_count': VRF.objects.count(),
'aggregate_count': Aggregate.objects.count(),
'prefix_count': Prefix.objects.count(),
'ipaddress_count': IPAddress.objects.count(),
'vlan_count': VLAN.objects.count(),
# DCIM
'rack_count': Rack.objects.count(),
'device_count': Device.objects.count(),
'interface_connections_count': InterfaceConnection.objects.count(),
'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(),
'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(),
# Circuits
'provider_count': Provider.objects.count(),
'circuit_count': Circuit.objects.count(),
# IPAM
'vrf_count': VRF.objects.count(),
'aggregate_count': Aggregate.objects.count(),
'prefix_count': Prefix.objects.count(),
'ipaddress_count': IPAddress.objects.count(),
'vlan_count': VLAN.objects.count(),
# Secrets
'secret_count': Secret.objects.count(),
# Circuits
'provider_count': Provider.objects.count(),
'circuit_count': Circuit.objects.count(),
}
# Secrets
'secret_count': Secret.objects.count(),
return render(request, 'home.html', {
'search_form': SearchForm(),
'stats': stats,
'recent_activity': UserAction.objects.select_related('user')[:50]
})
}
return render(request, self.template_name, {
'search_form': SearchForm(),
'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
'recent_activity': UserAction.objects.select_related('user')[:50]
})
class SearchView(View):
@@ -204,7 +210,7 @@ class APIRootView(APIView):
exclude_from_schema = True
def get_view_name(self):
return u"API Root"
return "API Root"
def get(self, request, format=None):
@@ -233,5 +239,6 @@ def trigger_500(request):
"""
Hot-wired method of triggering a server error to test reporting
"""
raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
"person you are.")
raise Exception(
"Congratulations, you've triggered an exception! Go tell all your friends what an exceptional person you are."
)

View File

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

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

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

View File

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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from rest_framework import routers
from . import views
@@ -22,4 +24,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

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
from __future__ import unicode_literals
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
@@ -6,7 +8,6 @@ from django.db.models import Count
from dcim.models import Device
from utilities.forms import BootstrapMixin, BulkEditForm, BulkImportForm, CSVDataField, FilterChoiceField, SlugField
from .models import Secret, SecretRole, UserKey
@@ -48,7 +49,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 +83,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,3 +1,5 @@
from __future__ import unicode_literals
from django.contrib.auth.hashers import PBKDF2PasswordHasher

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

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('secrets', '0002_userkey_add_session_key'),
]
operations = [
migrations.AlterField(
model_name='userkey',
name='public_key',
field=models.TextField(verbose_name='RSA public key'),
),
]

View File

@@ -1,4 +1,6 @@
from __future__ import unicode_literals
import os
from Crypto.Cipher import AES, PKCS1_OAEP, XOR
from Crypto.PublicKey import RSA
@@ -6,13 +8,12 @@ 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
from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
@@ -69,7 +70,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 +196,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 +284,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
@@ -301,8 +302,8 @@ class Secret(CreatedUpdatedModel):
def __str__(self):
if self.role and self.device:
return u'{} for {}'.format(self.role, self.device)
return u'Secret'
return '{} for {}'.format(self.role, self.device)
return 'Secret'
def get_absolute_url(self):
return reverse('secrets:secret', args=[self.pk])

View File

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

View File

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

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