Compare commits

...

431 Commits

Author SHA1 Message Date
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
1a34830f0e Merge pull request #1042 from digitalocean/v2-develop
Release v2.0 Beta 2
2017-04-05 11:57:04 -04:00
Jeremy Stretch
f000df1e15 Release v2.0-beta2 2017-04-05 11:52:14 -04:00
Jeremy Stretch
78b0072051 Limit <v2.0 installations to Django 1.10 2017-04-05 11:34:04 -04:00
Jeremy Stretch
178f7b4643 Added API endpoints for console and power connections 2017-04-05 11:29:12 -04:00
Jeremy Stretch
f34a8fff6a Extend upgrade script to remove cached bytecode 2017-04-05 10:22:37 -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
bde1f6d199 Bump migration index due to a new migration in 1.9.4-r1 2017-04-04 15:59:33 -04:00
Jeremy Stretch
0d7ee6f208 Merge branch 'develop' into v2-develop 2017-04-04 15:56:33 -04:00
Jeremy Stretch
78adaecb89 Post-release version bump 2017-04-04 15:50:59 -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
a18e1a0161 Release v1.9.4-r1 2017-04-04 15:47:25 -04:00
Jeremy Stretch
4308b8a4a5 Fixes #1034: Missing migration 2017-04-04 15:46:27 -04:00
Jeremy Stretch
280d98bad9 #1033: Updated requirements.txt for Django 1.11 2017-04-04 14:42:00 -04:00
Jeremy Stretch
ae5bf747c9 #1033: Tweak SelectWithDisabled option template 2017-04-04 14:36:35 -04:00
Jeremy Stretch
1ae0820ecc #1033: Update ArrayFieldSelectMultiple for Django 1.11 2017-04-04 14:30:00 -04:00
Jeremy Stretch
c09473f41e #1033: Convert SelectWithDisabled to a templatized widget 2017-04-04 14:19:48 -04:00
Jeremy Stretch
99a3e0c399 Corrected CustomFieldModelSerializer behavior when serializing lists of objects 2017-04-04 14:09:14 -04:00
Jeremy Stretch
d2bd4a213b #1033: Convert ColorSelect to a templatized widget 2017-04-04 13:45:32 -04:00
Jeremy Stretch
1dcb0b52e2 #1033: Disable the debug toolbar templates panel by default due to a performance issue under Django 1.11 2017-04-04 13:44:55 -04:00
Jeremy Stretch
409c9c4e23 Merge branch 'develop' into v2-develop
Conflicts:
	netbox/netbox/settings.py
2017-04-04 12:06:49 -04:00
Jeremy Stretch
aa54e14c37 Post-release version bump 2017-04-04 12:03:26 -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
3b2c74042e Release v1.9.4 2017-04-04 11:58:44 -04:00
Jeremy Stretch
11ae938146 Fixes #1027: Fixed nav menu highlighting when BASE_PATH is set 2017-04-04 11:55:16 -04:00
Stephen
f11bb254a5 Only show Custom Fields on IP Address Assign Page if custom fields are set against the ip address (#1031) 2017-04-04 11:37:20 -04:00
Jeremy Stretch
5187138547 Refactored custom field serializers 2017-04-04 11:25:23 -04:00
Jeremy Stretch
2bb6387dae Fixes #1028: Corrected API endpoint URL name in IPAddressForm 2017-04-03 17:11:22 -04:00
Jeremy Stretch
ca293dc0e7 Corrected topology map link 2017-04-03 16:34:37 -04:00
Jeremy Stretch
ea1d4e7f50 Updated static CSS/JS libraries 2017-04-03 16:15:06 -04:00
Jeremy Stretch
0b681c471e Removed survey notice 2017-04-03 16:01:03 -04:00
Jeremy Stretch
80267aa418 Documented image attachments 2017-04-03 15:56:04 -04:00
Jeremy Stretch
36c31a21b9 Fixed deprecated references to ImageAttachment.obj 2017-04-03 15:51:45 -04:00
Jeremy Stretch
51725d3d9c Added a search box to the navigation menu 2017-04-03 15:33:41 -04:00
Jeremy Stretch
05d3354570 Fixes #1022: Record user actions when creating IP addresses in bulk 2017-04-03 14:45:20 -04:00
Jeremy Stretch
8799a15e73 What would we do without you, PEP8? 2017-04-03 14:26:20 -04:00
Jeremy Stretch
2cde9a82a0 Merge pull request #1026 from digitalocean/image-attachments
#152: Image attachments
2017-04-03 14:21:15 -04:00
Jeremy Stretch
2c1fa628a2 Implemented API endpoints for ImageAttachments 2017-04-03 14:00:15 -04:00
Jeremy Stretch
a67fc64afb Fixes #1025: Applied missing API view filters 2017-04-03 11:04:17 -04:00
Jeremy Stretch
6bbdc2bae1 Enable serving static media through Django 2017-03-31 15:51:17 -04:00
Jeremy Stretch
50d7fd776f Added image attachments to sites and devices 2017-03-31 15:19:44 -04:00
Jeremy Stretch
1c38f705a7 Fixes #1021: Corrected evaluation of API token expiration time 2017-03-31 11:13:37 -04:00
Jeremy Stretch
b643939cc4 Initial work on #152: Image attachments 2017-03-30 21:55:57 -04:00
Jeremy Stretch
3ed3e93b25 Appended a version flag to all CSS/JS references to invalidate browser cache after an upgrade 2017-03-30 15:40:00 -04:00
Jeremy Stretch
f6ea09e581 Removed duplicate 'Regions' section 2017-03-30 10:27:20 -04:00
Jeremy Stretch
998f89216e Updated the docstring for Device 2017-03-30 09:56:47 -04:00
Jeremy Stretch
aefc6ff7b4 Merge branch 'global-search' into v2-develop 2017-03-29 16:45:57 -04:00
Jeremy Stretch
66615f1a96 Prettied things up a bit 2017-03-29 16:45:25 -04:00
Jeremy Stretch
a5dc91c175 Introduced SearchTable for improved performance 2017-03-29 16:05:23 -04:00
Jeremy Stretch
d04436aa0a Search form improvements 2017-03-29 14:22:27 -04:00
Jeremy Stretch
6542b8b198 Base64 decoding tweaks 2017-03-29 13:39:59 -04:00
Jeremy Stretch
6813787fc7 Fixes #1013: Show edit/delete reservation buttons on rack view 2017-03-29 12:15:14 -04:00
Jeremy Stretch
afdb24610d Initial work on global search 2017-03-29 12:04:57 -04:00
Jeremy Stretch
58e4bf1cc3 Closes #973: Removed extraneous admin UI functions 2017-03-28 16:41:53 -04:00
Jeremy Stretch
28761fc960 Closes #362: Added per_page query parameter to control pagination page length 2017-03-28 15:57:50 -04:00
Jeremy Stretch
69e54ab410 Token admin form improvements 2017-03-28 12:19:08 -04:00
Jeremy Stretch
116ceb6f93 Added tests for get-session-key API endpoint 2017-03-28 11:30:38 -04:00
Jeremy Stretch
5d022a575a Closes #985: Added preserve_key to get-session-key endpoint 2017-03-28 11:13:13 -04:00
Jeremy Stretch
e8fd0f3531 Order interfaces naturally for Device A 2017-03-27 10:55:54 -04:00
Jeremy Stretch
8103c399d5 Fixes #991: Correct server error on "create and connect another" interface connection 2017-03-27 10:53:32 -04:00
Jeremy Stretch
cf3e7f90d6 Merge pull request #997 from digitalocean/livesearch-fix
Fixes #996
2017-03-27 09:38:48 -04:00
Zach Moody
22bfac746e fix remaining legacy api url paths. 2017-03-24 18:58:19 -05:00
Zach Moody
066a3b8b52 update api_url with new interfaces endpoint. 2017-03-24 18:42:23 -05:00
Zach Moody
48141c0693 Fixes #996 2017-03-24 17:38:06 -05:00
Jeremy Stretch
576e21eb65 Merge branch 'develop' into v2-develop
Conflicts:
	netbox/netbox/settings.py
2017-03-23 17:14:41 -04:00
Jeremy Stretch
a51f5edbc8 Post-release version bump 2017-03-23 16:29:42 -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
ef59f38ec4 Release v1.9.3 2017-03-23 16:24:35 -04:00
Jeremy Stretch
47120fae01 Rack assignment is optional for devices 2017-03-23 15:36:24 -04:00
Jeremy Stretch
93a4327921 Merge branch 'api2' into v2-develop 2017-03-23 13:36:09 -04:00
Jeremy Stretch
0f2bbd7bfd Merge pull request #986 from alejojo/patch-1
Update Dockerfile
2017-03-23 10:28:10 -04:00
Jeremy Stretch
c0417c1989 Closes #972: Add ability to filter connections list by device name 2017-03-23 10:07:02 -04:00
Jeremy Stretch
fb6cfa45fd Merge pull request #974 from marc-us/develop
Filter on mac address on interface
2017-03-23 09:35:01 -04:00
Mark
b875cea10d Filter on mac address on interface via API 2017-03-23 12:57:35 +01:00
Alejandro
516372e5db Update Dockerfile
change branch MASTER > v2.0-beta1
2017-03-23 18:39:14 +09:00
Jeremy Stretch
0899a1052e Only attempt to process session key if user is authenticated 2017-03-22 17:43:29 -04:00
Jeremy Stretch
32bf17c076 Closes #978: Allow filtering device types by function and subdevice role 2017-03-22 17:29:47 -04:00
Jeremy Stretch
66a6a8f33c Closes #983: Include peer device names when listing circuits in device view 2017-03-22 16:58:56 -04:00
Jeremy Stretch
007fe6a030 Markdown fixes 2017-03-22 10:49:20 -04:00
Jeremy Stretch
f5f9491811 v2.0 Beta 1 release 2017-03-22 10:07:37 -04:00
Jeremy Stretch
04e09c0078 Merge branch 'develop' into api2
Conflicts:
	netbox/circuits/filters.py
2017-03-22 09:48:41 -04:00
Jeremy Stretch
05b71564d8 Closes #981: Allow filtering primary objects by a given set of IDs 2017-03-22 09:39:30 -04:00
Jeremy Stretch
1791a5bb11 Added has_primary_ip filter for Devices 2017-03-21 21:29:03 -04:00
Jeremy Stretch
3e6a99fc22 Allow editing of platform RPC client 2017-03-21 17:33:40 -04:00
Jeremy Stretch
a5419ecc5c RPC API fixes 2017-03-21 17:24:16 -04:00
Jeremy Stretch
a36b138efe Added API doc for working with secrets 2017-03-21 16:00:02 -04:00
Jeremy Stretch
6d30fdb83d Finished work on secrets views; removed path from cookie assignment 2017-03-21 15:30:36 -04:00
Jeremy Stretch
5c4741c5d4 Added section on pagination 2017-03-21 14:34:52 -04:00
Jeremy Stretch
93c748bd3c Merge branch 'develop' into api2 2017-03-21 14:10:53 -04:00
Jeremy Stretch
7ba6e320e7 Fixes #843: Implemented CORS headers for API 2017-03-21 13:53:07 -04:00
Jeremy Stretch
54468ab1a8 Include the API version in responses 2017-03-21 13:23:56 -04:00
Jeremy Stretch
01f5435f63 Tweak how we set the API version 2017-03-21 13:17:50 -04:00
Jeremy Stretch
22768ff6c6 Renamed Module to InventoryItem (prep for #824) 2017-03-21 12:54:08 -04:00
Jeremy Stretch
122526a9d0 Custom name for ConnectedDeviceViewSet 2017-03-20 21:54:01 -04:00
Jeremy Stretch
6cb36a6cee Fixed browsable API breadcrumbs 2017-03-20 21:50:10 -04:00
Jeremy Stretch
925afe0999 Added test case for ConnectedDeviceViewSet 2017-03-20 21:39:40 -04:00
Jeremy Stretch
f743410b4e Renamed rack-units API and added a test 2017-03-20 21:18:37 -04:00
Jeremy Stretch
4a2206ecb1 Removed custom renderers 2017-03-20 17:47:18 -04:00
Jeremy Stretch
ffde2c96c7 Fixed custom renderers to work with paginated data 2017-03-20 17:15:42 -04:00
Jeremy Stretch
2bd46230be Converted ChoiceFieldSerializer to display an object 2017-03-20 16:32:59 -04:00
Jeremy Stretch
b04fe21d65 Wrote API endpoints, tests for ExportTemplates 2017-03-20 16:21:10 -04:00
Jeremy Stretch
266f9cc370 Added API endpoint, tests for Graphs 2017-03-20 15:14:33 -04:00
Jeremy Stretch
1682de59df Added a footer link to the GitHub wiki 2017-03-20 14:05:26 -04:00
Jeremy Stretch
42fd14f5c0 Introduced HttpStatusMixin to provide more detail on HTTP response status test failures 2017-03-20 13:46:47 -04:00
Jeremy Stretch
1988c02b7f Enforce API versioning 2017-03-20 12:33:42 -04:00
Jeremy Stretch
517eaa8b80 Expanded API documentation 2017-03-20 12:18:18 -04:00
Jeremy Stretch
1f78462f58 Updated RackViewSet() to be compatible with paginated API 2017-03-20 10:38:09 -04:00
Jeremy Stretch
36bbcc8559 Fix API JS to read response.results for new API 2017-03-20 10:06:25 -04:00
Mark
f26253ec49 Filter on mac address on interface 2017-03-18 21:26:33 +01:00
Mark
f2dc287f14 Filter on mac address on interface 2017-03-18 21:21:49 +01:00
Mark
3fe3151af7 Filter on mac address on interface
Extension to be able filter on mac address via API
2017-03-18 21:10:36 +01:00
Jeremy Stretch
1c1fd8f210 Limit tests to one per major Python version 2017-03-17 21:43:46 -04:00
Jeremy Stretch
671d53877a Python3 fixes 2017-03-17 21:39:29 -04:00
Jeremy Stretch
97710a4576 Make CI happy 2017-03-17 17:39:56 -04:00
Jeremy Stretch
c08fae8bce Restore not-so-extraneous 'id' field to all WritableSerializers 2017-03-17 17:32:43 -04:00
Jeremy Stretch
f02dd2f439 Merge branch 'develop' into api2 2017-03-17 17:06:01 -04:00
Jeremy Stretch
e544f1fa1e Removed extraneous 'id' field from all WritableSerializers 2017-03-17 16:20:34 -04:00
Jeremy Stretch
130ff27f26 Wrote tests for secrets API 2017-03-17 16:01:57 -04:00
Jeremy Stretch
79a9ac3bc8 Assign RackReservation user from request context 2017-03-17 14:45:14 -04:00
Jeremy Stretch
c5308d51f4 Make RackReservation.rack editble for API compatability 2017-03-17 14:40:11 -04:00
Jeremy Stretch
a6f4de5817 Wrote tests for IPAM API 2017-03-17 14:36:59 -04:00
Jeremy Stretch
8825a03033 Removed unneeded services endpoint from DCIM API 2017-03-17 12:23:23 -04:00
Jeremy Stretch
abdfc5c597 Finished DCIM API model tests 2017-03-17 12:16:24 -04:00
Jeremy Stretch
3ce2f0d100 Fix error when assigning a new interface to a LAG 2017-03-16 22:27:01 -04:00
Jeremy Stretch
be2faaa110 Fixed bug interpreting facility_id as a required field 2017-03-16 17:25:34 -04:00
Jeremy Stretch
f33269e50b First batch of DCIM API tests 2017-03-16 16:50:18 -04:00
Jeremy Stretch
bbc355df07 Improved create/update validation 2017-03-16 14:17:14 -04:00
Jeremy Stretch
d58f9031d1 Wrote tests for tenancy API 2017-03-16 13:29:55 -04:00
Jeremy Stretch
0312016f89 Wrote tests for circuits API 2017-03-16 13:23:01 -04:00
Jeremy Stretch
e3ae013e42 Implemented full read/write support for secrets 2017-03-15 14:47:18 -04:00
Jeremy Stretch
07a2b136b8 Refactored SecretViewSet 2017-03-15 13:48:09 -04:00
Jeremy Stretch
3d76a982aa Removed old API doc 2017-03-15 13:15:09 -04:00
Jeremy Stretch
92d726bbd4 Added examples to the graphs documentation 2017-03-15 12:16:46 -04:00
Jeremy Stretch
e2ef0bc3a6 Added survey announcement to README 2017-03-15 12:00:53 -04:00
Jeremy Stretch
13c29cb7a9 Post-release version bump 2017-03-14 17:18:05 -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
b22c6a0078 Release v1.9.2 2017-03-14 17:07:04 -04:00
Jeremy Stretch
f4784412de Fixes #964: Fix bug when bulk editing/deleting filtered set of objects 2017-03-14 15:22:08 -04:00
Jeremy Stretch
33c5ea1f4e Fixes #963: Fix bug in IPv6 address range expansion 2017-03-14 15:06:34 -04:00
Jeremy Stretch
3dc15068b9 Allow user to delete session key 2017-03-14 14:01:06 -04:00
Jeremy Stretch
4cb30f1ce4 Relate SessionKey to UserKey rather than User 2017-03-14 13:32:07 -04:00
Jeremy Stretch
b868de8d67 Updated user URLs 2017-03-14 12:59:10 -04:00
Jeremy Stretch
04aedcc056 Merge branch 'develop' into api2
Conflicts:
	netbox/templates/users/_user.html
	netbox/users/urls.py
2017-03-14 12:40:28 -04:00
Jeremy Stretch
d9f1bcbf15 Renamed user URL namespace 2017-03-14 12:36:44 -04:00
Jeremy Stretch
105d17748e Secrets UI work 2017-03-14 12:32:08 -04:00
Jeremy Stretch
dd27950fae Simplify SessionKey usage 2017-03-14 10:58:57 -04:00
Jeremy Stretch
4b7af8d3b4 Merge pull request #954 from psr/develop
Force Unix line endings on shell scripts
2017-03-13 11:35:05 -04:00
Jeremy Stretch
f3fd82a24a Allow assigning child devices to rackless parents 2017-03-13 11:31:28 -04:00
Jeremy Stretch
cd97b2fb96 Fix parent device position display 2017-03-13 11:25:06 -04:00
Jeremy Stretch
f661c233be Fixes #950: Fix site_id error on child device import 2017-03-13 11:18:33 -04:00
Jeremy Stretch
6a2a2d5d11 Fixes #957: Correct device site filter count to include unracked devices 2017-03-13 10:13:04 -04:00
Jeremy Stretch
87ff433ef8 Fixes #956: Correct bug affecting unnamed rackless devices 2017-03-13 10:06:32 -04:00
Jeremy Stretch
9e4e3a8dfa Updated API docs 2017-03-13 10:00:13 -04:00
Jeremy Stretch
4d4441217f APIRootView tweaks 2017-03-09 15:18:50 -05:00
Jeremy Stretch
7e51ca9912 Provided a root API view 2017-03-09 15:05:01 -05:00
Jeremy Stretch
94a29be415 Removed deprecated GraphListView 2017-03-09 14:28:52 -05:00
Jeremy Stretch
9dfda83946 Closes #855: Added an API endpoint for recent activity 2017-03-09 14:26:39 -05:00
Jeremy Stretch
41826fc3cb Fixed serialization of CustomFieldChoices 2017-03-09 13:50:30 -05:00
Jeremy Stretch
0ed13f6943 Removed browsable API login/logout 2017-03-09 13:38:15 -05:00
Jeremy Stretch
6c2ed1be22 Standardized API URL definitions 2017-03-09 13:24:02 -05:00
Jeremy Stretch
ddec424429 Replaced RelatedConnectionsView with views.ConnectedDeviceViewSet 2017-03-09 12:18:53 -05:00
Peter Russell
d68b34cefe Force Unix line endings on shell scripts
When following the quickstart docker instructions on Windows (using
Docker for Windows), an error is encountered when running the netbox
container. This is caused by git converting the line endings of the
docker-entrypoint.sh script to Windows format, which are then copied
into the container image.

By setting .gitattributes, we force LF rather than CRLF line endings on
shell scripts on Windows. Other files are left as is.
2017-03-09 16:20:32 +00:00
Jeremy Stretch
7e6d061646 Converted GetSessionKey and RSAKeyGeneratorView to ViewSets 2017-03-08 17:57:51 -05:00
Jeremy Stretch
c19725506d Cleanup 2017-03-08 16:30:32 -05:00
Jeremy Stretch
a6ceaf8d96 Moved custom field serializers to their own module to avoid circular dependency 2017-03-08 16:18:41 -05:00
Jeremy Stretch
f43fbffdf7 Moved TopologyMaps from DCIM to extras 2017-03-08 16:12:14 -05:00
Jeremy Stretch
68c099a2af Merge branch 'develop' into api2
Conflicts:
	netbox/netbox/settings.py
2017-03-08 15:18:32 -05:00
Jeremy Stretch
70a05b4280 Post-release version bump 2017-03-08 14:45:23 -05: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
094974d417 Release v1.9.1 2017-03-08 14:38:24 -05:00
Jeremy Stretch
d89314a559 Fixes #943: Child prefixes missing on Python 3 2017-03-08 14:10:27 -05:00
Jeremy Stretch
086340540a Fixes #948: Region name should be hyperlinked to site list 2017-03-08 12:04:50 -05:00
Jeremy Stretch
ed83b1d9e9 Closes #946: Disregard mask length when filtering IP addresses by a parent prefix 2017-03-08 11:59:24 -05:00
Jeremy Stretch
4f6d2a8b71 Finished user control panel for tokens 2017-03-08 11:34:47 -05:00
Jeremy Stretch
d58a8ebba0 Initial work on user control panel for tokens 2017-03-07 23:30:53 -05:00
Jeremy Stretch
6be465fe9b Addded is_expired property to Token 2017-03-07 23:30:31 -05:00
Jeremy Stretch
26225aff57 Shorten key length to 20 bytes 2017-03-07 22:56:29 -05:00
Jeremy Stretch
fd55360672 Suppress default permissions for Token model 2017-03-07 22:40:05 -05:00
Jeremy Stretch
4e766c7c3b Closes #945: Display the current user in the nav menu 2017-03-07 22:27:46 -05:00
Jeremy Stretch
0b10d98e0b Initial work on token authentication 2017-03-07 17:17:39 -05:00
Jeremy Stretch
be0a3fb1f2 Corrected merge conflict 2017-03-07 16:55:49 -05:00
Jeremy Stretch
02e89d77bb Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/views.py
2017-03-07 14:08:06 -05:00
Jeremy Stretch
4b753b1610 Fixes #944: Correct console and power connection form behavior 2017-03-07 13:32:48 -05:00
Jeremy Stretch
f8381628d4 Fixes #941: Corrected old references to rack.site on Device 2017-03-06 10:23:24 -05:00
Jeremy Stretch
d8f41f67c9 Post-release version bump 2017-03-03 11:28:27 -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
ee2d0b963d Release v1.9.0-r1 2017-03-03 11:26:31 -05:00
Jeremy Stretch
f051c0e564 Fixes #935: Fix form validation error when connecting an interface using live search 2017-03-03 11:19:19 -05:00
Jeremy Stretch
03d3bbcddb Fixes #938: Provider view yields an error if one or more circuits is assigned to a tenant 2017-03-03 10:47:16 -05:00
Jeremy Stretch
06cafb09b3 Fixes #937: Region assignment should be optional when creating a site 2017-03-03 10:44:07 -05:00
Jeremy Stretch
a7a7b956b1 Enable API versioning 2017-03-02 16:20:16 -05:00
Jeremy Stretch
9b39ba169c Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/urls.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
	netbox/dcim/tables.py
	requirements.txt
2017-03-02 16:01:25 -05:00
Jeremy Stretch
4670929953 Added GitHub issue/PR templates; updated CONTRIBUTING policy 2017-03-02 15:30:14 -05:00
Jeremy Stretch
366e2e7a94 Post-release version bump 2017-03-02 13:30:45 -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
e5f05ca9be Release v1.9.0 2017-03-02 13:22:29 -05:00
Jeremy Stretch
d08522408a Documentation refresh 2017-03-02 13:21:56 -05:00
Jeremy Stretch
cc31c8fc33 Closes #894: Expand device name max length to 64 characters 2017-03-02 10:11:54 -05:00
Jeremy Stretch
db60e8868c Closes #901: Support for filtering prefixes and IP addresses by mask length 2017-03-01 14:23:52 -05:00
Jeremy Stretch
1adae67dd7 Closes #927: Upgrade to django-filter 1.0 2017-03-01 13:09:19 -05:00
Jeremy Stretch
5ad3044314 Closes #862: Show both IPv6 and IPv4 primary IPs in device list 2017-02-28 16:46:44 -05:00
Jeremy Stretch
90fe556e5f Corrected region serializers 2017-02-28 16:23:39 -05:00
Jeremy Stretch
c0152940f9 Merged develop 2017-02-28 16:10:53 -05:00
Jeremy Stretch
9313ba08ed Implemented recursive regions with django-mptt 2017-02-28 14:15:15 -05:00
Jeremy Stretch
f3b9930dea Initial work on regions 2017-02-28 12:11:43 -05:00
Jeremy Stretch
5520144ff4 Fixed permissions for inclusion of interface bulk edit form 2017-02-27 17:19:30 -05:00
Jeremy Stretch
2b9ea58c86 Tweaked interface LAG validation 2017-02-27 17:13:58 -05:00
Jeremy Stretch
8f42f59a80 Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
2017-02-27 17:04:08 -05:00
Jeremy Stretch
c6970e1998 Closes #105: Interface groups (#919)
* Initial work on interface groups

* Simplify to a single LAG form factor

* Correct interface serializer

* Allow for bulk editing of interface LAG

* Additional LAG interface validation

* Fixed API tests
2017-02-27 16:52:13 -05:00
Jeremy Stretch
c61bae3a33 Merge pull request #908 from digitalocean/global-vlans
Closes #235: Global vlans
2017-02-21 14:58:52 -05:00
Jeremy Stretch
b0f9035e2d Additional work on #904 2017-02-21 14:53:22 -05:00
Shawn Peng
aba9748ffd Fix #235: Enable global vlan (#904)
* Fix #235: Enable global vlan

Decouple site/vlan, make site optional for vlan/vlangroup
Change html generation code to check site existence before
dereference
Create site search function, if site is None for a VLAN, view it as
global VLAN

* commit1

* commit2

* commit3

* Add migration file for VLAN&VLAN group

* Revert unintentional commits
2017-02-21 13:27:24 -05:00
Jeremy Stretch
2876ef7607 Merge pull request #907 from Jasperswaagman/develop
Typo
2017-02-21 09:24:17 -05:00
Jasperswaagman
7d1aeede1a Typo 2017-02-21 15:20:42 +01:00
Jeremy Stretch
b7f4a11eee Fixes #892: Restored missing edit/delete buttons when viewing child prefixes and IP addresses from a parent object 2017-02-17 16:34:09 -05:00
Jeremy Stretch
0e5138d6ec Fixes #872: TypeError on bulk IP address creation (Python 3) 2017-02-17 16:10:07 -05:00
Jeremy Stretch
f1518226bd Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/serializers.py
2017-02-17 15:12:53 -05:00
Jeremy Stretch
4d26fc7e7c Fixes #903: Only alert on missing criticial connections if present in the parent device type 2017-02-17 15:10:08 -05:00
Jeremy Stretch
102cf52a16 Cleanup from work on #198 2017-02-17 14:52:58 -05:00
Jeremy Stretch
198ed859ff Closes #198: Support for rackless devices (#902)
* Initial work to support rackless devices

* Updated device component connection forms

* Updated IP address assignment form

* Updated circuit termination form

* Formatting cleanup

* Fixed tests
2017-02-17 14:48:00 -05:00
Jeremy Stretch
9d44d5d4e7 Fixes #897: Fixed power connections CSV export 2017-02-16 15:17:13 -05:00
Jeremy Stretch
e8896fe238 Closes #898: Expand circuits list in provider view 2017-02-16 15:13:35 -05:00
Jeremy Stretch
21281789e0 Tweaked ChoiceFieldSerializer to display a field as (value, label) 2017-02-16 14:37:21 -05:00
Jeremy Stretch
b71566f206 Merge branch 'develop' into api2
Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/urls.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
2017-02-16 14:28:06 -05:00
Jeremy Stretch
181539651f Rack reservations (#900)
* Initial work on rack reservations

* Added views for rack reservations

* Implemented ArrayFieldSelectMultiple form widget

* Implemented API endpoints for rack reservations

* Tweaked the database migration
2017-02-16 13:46:58 -05:00
Jeremy Stretch
b69564f5c9 Exposed the request to ObjectEditView's alter_obj() 2017-02-15 15:03:31 -05:00
Jeremy Stretch
c26e00b5bd Merge pull request #850 from mryauch/patch-1
Update upgrading.md
2017-02-14 15:45:06 -05:00
Jeremy Stretch
dc606645fd Fixes #884: Preserve selected rack unit when changing a device's rack face 2017-02-09 23:27:12 -05:00
Jeremy Stretch
0e04d20762 Re-implemented CustomFieldSerializer (read-only for now) 2017-02-09 16:55:54 -05:00
Jeremy Stretch
7040086201 Introduced ChoiceFieldSerializer for choice fields 2017-02-09 15:50:25 -05:00
Jeremy Stretch
6f3c3b6d61 Added API endpoints for device type components 2017-02-03 17:18:47 -05:00
Jeremy Stretch
37f250ddc1 Corrected API URL names 2017-02-03 16:54:13 -05:00
Jeremy Stretch
35f310885e Standardize API URL inclusions 2017-02-03 16:20:14 -05:00
Jeremy Stretch
616ca4fe1f Adapted the web UI to work with the new secrets API 2017-02-03 16:14:42 -05:00
Jeremy Stretch
a9fe39459a Merge branch 'develop' into api2 2017-02-03 14:45:37 -05:00
Jeremy Stretch
1e1dd8c668 Post-release version bump 2017-02-03 13:59:26 -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
585e08eb95 Release v1.8.4 2017-02-03 13:55:32 -05:00
Jeremy Stretch
a42eeb12d2 Implemented SessionKeys for secrets 2017-02-03 12:49:32 -05:00
Jeremy Stretch
cf66f67fb6 Initial work on using session-based master key ciphers 2017-02-02 21:26:51 -05:00
Jeremy Stretch
2408d78f47 Introduced ability to decrypt secrets by sending the user's private key in an HTTP header 2017-02-01 17:40:50 -05:00
Jeremy Stretch
4f8a5eb1a0 Moved secret views into a ViewSet (no write ability yet) 2017-02-01 16:21:33 -05:00
Jeremy Stretch
06e5966cb4 Include API routers directly where possible 2017-02-01 15:09:23 -05:00
Jeremy Stretch
ea51f1c896 Removed circuit-specific endpoint for CircuitTerminations 2017-02-01 15:01:56 -05:00
Jeremy Stretch
77e5450746 Removed all device-specific API endpoints 2017-02-01 14:34:19 -05:00
Jeremy Stretch
6e10fea119 Started API documentation 2017-02-01 14:04:45 -05:00
Jeremy Stretch
f52c247bd5 Re-implemented Swagger now that URL resolution has been fixed 2017-02-01 12:37:19 -05:00
Jeremy Stretch
0dd857f7a2 Merge branch 'develop' into api2 2017-02-01 12:33:37 -05:00
Jeremy Stretch
d817990283 Fixes #865: Fix server error when attempting to delete a protected object parent (Python 3) 2017-02-01 12:09:59 -05:00
Jeremy Stretch
9905099a71 Fixes #854: Check whether object still exists before attempting to resolve its URL 2017-02-01 11:59:47 -05:00
Jeremy Stretch
0eba5a0de3 Fixes #851: Resolve encoding issues during import/export with Python 3 2017-02-01 11:49:54 -05:00
Jeremy Stretch
5eb3c1a67b Removed deprecated base_path Swagger setting 2017-02-01 10:48:36 -05:00
Jeremy Stretch
b370375414 Fixes #861: Avoid overwriting device primary IP assignment from alternate family during bulk import of IP addresses 2017-01-31 17:25:44 -05:00
Jeremy Stretch
8536f6c163 Closes #856: Strip whitespace from fields during CSV import 2017-01-31 16:54:13 -05:00
Jeremy Stretch
bb1f97abc2 Implemented static writable ModelSerializers for all models 2017-01-31 15:35:09 -05:00
Jeremy Stretch
e1cd846c9a Enabled creation of device components 2017-01-31 12:19:41 -05:00
Jeremy Stretch
1fcc2b0029 Namespaced all API URLs 2017-01-31 10:40:53 -05:00
Jeremy Stretch
f4f41a5985 Fixes #859: Fix Javascript for connection status toggle button 2017-01-31 09:41:25 -05:00
Jeremy Stretch
173a6eee03 Moved rack units and device LLDP neighbors views into model viewsets 2017-01-30 17:24:04 -05:00
Jeremy Stretch
d9e4017677 Moved graph views into model viewsets 2017-01-30 17:00:58 -05:00
Jeremy Stretch
7beac0b105 Converted device component views to a router 2017-01-30 16:15:12 -05:00
Jeremy Stretch
f0fef94a4f Re-implemented interface/connection serializers 2017-01-30 15:35:01 -05:00
Jeremy Stretch
78cd4481e4 Merge branch 'develop' into api2 2017-01-30 13:38:49 -05:00
Jeremy Stretch
af3c9eaec1 Fixes #854: Correct processing of get_return_url() in ObjectDeleteView 2017-01-30 12:13:24 -05:00
Jeremy Stretch
0cf029edd4 Added Service serializers 2017-01-27 16:19:38 -05:00
Jeremy Stretch
c0dac1383d Fix retrieval of model under viewsets without a statically defined queryset 2017-01-27 15:12:46 -05:00
Jeremy Stretch
a3d0d4a5bf Enabled pagination 2017-01-27 14:54:12 -05:00
Jeremy Stretch
12d263999b Introduced WritableSerializerMixin 2017-01-27 14:36:13 -05:00
Jeremy Stretch
fa900d5dbb Converted nested serializers to HyperlinkedModelSerializer 2017-01-27 12:22:29 -05:00
Jeremy Stretch
ddc2c8d110 Cleaned up device component nested serializers 2017-01-26 22:37:17 -05:00
Matthew Yauch
c91f41e984 Update upgrading.md
Added directive to copy the LDAP configuration if in use.  I upgraded a whole two versions before realizing my LDAP was broken.
2017-01-26 15:28:11 -08:00
Jeremy Stretch
acfba410dd Standardized implementation of nested ViewSets 2017-01-26 17:58:36 -05:00
Jeremy Stretch
b8ca530c55 Added an endpoint for CircuitTerminations 2017-01-26 17:18:41 -05:00
Jeremy Stretch
b31c097531 Removed Swagger 2017-01-26 15:36:19 -05:00
Jeremy Stretch
0f9fe8648e Converted static URL definitions to routers 2017-01-26 15:34:07 -05:00
Jeremy Stretch
791a641eef Created CircuitDetailSerializer 2017-01-26 15:33:41 -05:00
Jeremy Stretch
c5fba24cc5 Merge branch 'develop' into api2 2017-01-26 14:07:23 -05:00
Jeremy Stretch
b8b2ea7ccb Post-release version bump 2017-01-26 14:00:08 -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
b2ef7bb104 Release v1.8.3 2017-01-26 13:57:00 -05:00
Jeremy Stretch
5d5d4ac714 Fixes #845: Fix missing edit/delete buttons on object tables for non-superusers 2017-01-26 13:20:56 -05:00
Jeremy Stretch
0b228ed6d3 Merge branch 'develop' into api2 2017-01-25 16:26:45 -05:00
dav3860
b3b96e5e10 Support for comma in interfaces and ip addresses bulk creation (#833)
* Added support for comma in interfaces and ip addresses bulk creation

* fixed PEP8 style

* removed unnecessary assertions
2017-01-25 14:47:14 -05:00
Jeremy Stretch
6be520a8f9 Fixed DeviceTypeTest 2017-01-25 14:38:45 -05:00
Jeremy Stretch
f3db914e9d Fixes #844: Apply order_naturally() to API interfaces list 2017-01-25 14:34:34 -05:00
Jeremy Stretch
062a5bfe8d Initial work on API v2.0 2017-01-24 17:12:16 -05:00
Jeremy Stretch
fbfa3cf619 Added gunicorn_config.py to .gitignore 2017-01-24 13:41:46 -05:00
Jeremy Stretch
1317c0dd8c Closes #841: Merged search and filter forms on all object lists 2017-01-24 12:05:39 -05:00
Jeremy Stretch
bbc633b004 Closes #782: Allow filtering devices list by manufacturer 2017-01-24 10:53:59 -05:00
Jeremy Stretch
ed8fdd9292 Fixes #816: Redirect back to parent prefix view after deleting child prefixes 2017-01-24 09:50:51 -05:00
Jeremy Stretch
2d9c33c34f Tweaked installation docs to include instructions for Python 2 and 3 2017-01-23 17:01:23 -05:00
Jens L
80439c495e Basic Support for Python 3 (#827)
* Rudimentary python3 support

* update docs and trigger Travis

* fix some of the tests

* fix all python3 errors

* change env calls to just python

* add @python_2_unicode_compatible decorator to models for python2 compatibility

* switch netbox.configuration to from netbox import configuration
2017-01-23 16:44:29 -05:00
Jeremy Stretch
1bddd038fe Fixes #840: Correct API path resolution for secrets when BASE_PATH is configured 2017-01-23 16:25:05 -05:00
Jeremy Stretch
d36923e47d Fixes #817: Update last_updated time of a circuit when editing a child termination 2017-01-23 15:31:41 -05:00
Jeremy Stretch
476cbf17f6 Closes #820: Add VLAN column to parent prefixes table on IP address view 2017-01-23 14:23:42 -05:00
Jeremy Stretch
91d50b9627 Closes #836: Add 'deprecated' status for IP addresses 2017-01-23 14:12:43 -05:00
Jeremy Stretch
52420945b2 Standardized naming of return_url for all object views 2017-01-23 14:07:26 -05:00
Jeremy Stretch
b70eca7661 Fixes #830: Redirect user to device view after editing a device component 2017-01-23 12:14:12 -05:00
Jeremy Stretch
39d083eae7 Re-implemented method for bulk editing/deleting all objects within a filtered queryset 2017-01-20 16:42:11 -05:00
Jeremy Stretch
3bfc1ebcea Post-release version bump 2017-01-18 16:23:52 -05:00
377 changed files with 33119 additions and 19298 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

28
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,28 @@
<!--
Please note: GitHub issues are to be used only for feature requests
and bug reports. For installation assistance or general discussion,
please join us on the mailing list:
https://groups.google.com/forum/#!forum/netbox-discuss
Please indicate "bug report" or "feature request" below. Be sure to
search the existing set of issues (both open and closed) to see if
a similar issue has already been raised.
-->
### Issue type:
<!--
If filing a bug, please indicate the version of Python and NetBox
you are running. (This is not necessary for feature requests.)
-->
**Python version:**
**NetBox version:**
<!--
If filing a bug, please record the exact steps taken to reproduce
the bug and any errors messages that are generated.
If filing a feature request, please precisely describe the data
model or workflow you would like to see implemented, and provide a
use case.
-->

14
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,14 @@
<!--
Thank you for your interest in contributing to NetBox! Please note
that our contribution policy requires that a feature request or bug
report be opened for approval prior to filing a pull request. This
helps avoid wasting time and effort on something that we might not
be able to accept.
Please indicate the relevant feature request or bug report below.
-->
### Fixes:
<!--
Please include a summary of the proposed changes below.
-->

2
.gitignore vendored
View File

@@ -1,8 +1,10 @@
*.pyc
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/static
.idea
/*.sh
!upgrade.sh
fabfile.py
*.swp
gunicorn_config.py

View File

@@ -9,6 +9,7 @@ env:
language: python
python:
- "2.7"
- "3.5"
install:
- pip install -r requirements.txt
- pip install pep8

View File

@@ -1,84 +1,113 @@
## Getting Help
If you encounter any issues installing or using NetBox, try one of the following resources to get assistance. Please
**do not** open an issue on GitHub except to report bugs or request features.
### Freenode IRC
Join the #netbox channel on [Freenode IRC](https://freenode.net/). You can connect to Freenode at irc.freenode.net using
an IRC client, or you can use their [webchat client](https://webchat.freenode.net/).
If you encounter any issues installing or using NetBox, try one of the
following resources to get assistance. Please **do not** open a GitHub
issue except to report bugs or request features.
### Mailing List
We have established a Google Groups Mailing List for issues and general discussion. You can find us [here]( https://groups.google.com/forum/#!forum/netbox-discuss).
We have established a Google Groups Mailing List for issues and general
discussion. This is the best forum for obtaining assistance with NetBox
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
### Freenode IRC
For real-time discussion, you can join the #netbox channel on [Freenode](https://freenode.net/).
You can connect to Freenode at irc.freenode.net using an IRC client, or
you can use their [webchat client](https://webchat.freenode.net/).
## Reporting Bugs
* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) of
NetBox. If you're running an older version, it's possible that the bug has already been fixed.
NetBox. If you're running an older version, it's possible that the bug
has already been fixed.
* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the bug you've found has
already been reported. If you think you may be experiencing a reported issue that hasn't already been resolved, please
click "add a reaction" in the top right corner of the issue and add a thumbs up (+1). You might also want to add a
comment describing how it's affecting your installation. This will allow us to prioritize bugs based on how many users
are affected.
already been reported. If you think you may be experiencing a reported
issue that hasn't already been resolved, please click "add a reaction"
in the top right corner of the issue and add a thumbs up (+1). You might
also want to add a comment describing how it's affecting your
installation. This will allow us to prioritize bugs based on how many
users are affected.
* If you haven't found an existing issue that describes your suspected bug, please inquire about it on IRC or Google Groups.
**Do not** file an issue until you have received confirmation that it is in fact a bug. Invalid issues are very
distracting and slow the pace at which NetBox is developed.
* If you haven't found an existing issue that describes your suspected
bug, please inquire about it on the mailing list. **Do not** file an
issue until you have received confirmation that it is in fact a bug.
Invalid issues are very distracting and slow the pace at which NetBox is
developed.
* When submitting an issue, please be as descriptive as possible. Be sure to include:
* When submitting an issue, please be as descriptive as possible. Be
sure to include:
* The environment in which NetBox is running
* The exact steps that can be taken to reproduce the issue (if applicable)
* Any error messages returned
* The exact steps that can be taken to reproduce the issue (if
applicable)
* Any error messages generated
* Screenshots (if applicable)
* 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.
* 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.
## Feature Requests
* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) to see if the feature you're
requesting is already listed. (Be sure to search closed issues as well, since some feature requests are rejected.) If
the feature you'd like to see has already been requested, click "add a reaction" in the top right corner of the issue
and add a thumbs up. This ensures that the issue has a better chance of making it onto the roadmap. Also feel free
to add a comment with any additional justification for the feature. (However, note that comments with no substance
other than a "+1" will be deleted as spam. Please use GitHub's reactions feature to indicate your support.)
requesting is already listed. (Be sure to search closed issues as well,
since some feature requests are rejected.) If the feature you'd like to
see has already been requested, click "add a reaction" in the top right
corner of the issue and add a thumbs up (+1). This ensures that the
issue has a better chance of making it onto the roadmap. Also feel free
to add a comment with any additional justification for the feature.
(However, note that comments with no substance other than a "+1" will be
deleted. Please use GitHub's reactions feature to indicate your
support.)
* While suggestions for new features are welcome, it's important to limit the scope of NetBox's feature set to avoid
feature creep. For example, the following features would be firmly out of scope for NetBox:
* While suggestions for new features are welcome, it's important to
limit the scope of NetBox's feature set to avoid feature creep. For
example, the following features would be firmly out of scope for NetBox:
* Ticket management
* Network state monitoring
* Acting as a DNS server
* Acting as an authentication server
* Before filing a new feature request, propose it on IRC or Reddit first. Feedback you receive there will help validate
and shape the proposed feature before filing a formal issue.
* Before filing a new feature request, consider raising your idea on the
mailing list first. Feedback you receive there will help validate and
shape the proposed feature before filing a formal issue.
* Good feature requests are very narrowly defined. Be sure to enumerate specific functionality and data schema. The more
effort you put into writing a feature request, the better its chances are of being implemented. Overly broad feature
requests will be closed.
* Good feature requests are very narrowly defined. Be sure to enumerate
specific functionality and data schema. The more effort you put into
writing a feature request, the better its chance is of being
implemented. Overly broad feature requests will be closed.
* When submitting a feature request on GitHub, be sure to include the following:
* When submitting a feature request on GitHub, be sure to include the
following:
* A detailed description of the proposed functionality
* A use case for the feature; who would use it and what value it would add to NetBox
* A rough description of any changes necessary to the database schema
* Any third-party libraries or other resources which would be involved
* A use case for the feature; who would use it and what value it
would add to NetBox
* A rough description of changes necessary to the database schema
(if applicable)
* Any third-party libraries or other resources which would be
involved
## Submitting Pull Requests
* Be sure to open an issue before starting work on a pull request, and discuss your idea with the NetBox maintainers
before beginning work. This will help prevent wasting time on something that might we might not be able to implement.
When suggesting a new feature, also make sure it won't conflict with any work that's already in progress.
* Be sure to open an issue before starting work on a pull request, and
discuss your idea with the NetBox maintainers before beginning work.
This will help prevent wasting time on something that might we might not
be able to implement. When suggesting a new feature, also make sure it
won't conflict with any work that's already in progress.
* When submitting a pull request, please be sure to work off of the `develop` branch, rather than `master`. In NetBox,
the `develop` branch is used for ongoing development, while `master` is used for tagging new stable releases.
* When submitting a pull request, please be sure to work off of the
`develop` branch, rather than `master`. In NetBox, the `develop` branch
is used for ongoing development, while `master` is used for tagging new
stable releases.
* All code submissions should meet the following criteria (CI will enforce these checks):
* All code submissions should meet the following criteria (CI will
enforce these checks):
* Python syntax is valid
* All tests pass when run with `./manage.py test netbox/`
* PEP 8 compliance is enforced, with the exception that lines may be greater than 80 characters in length
* All tests pass when run with `./manage.py test`
* PEP 8 compliance is enforced, with the exception that lines may be
greater than 80 characters in length

View File

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

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

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

View File

@@ -0,0 +1,48 @@
The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API.
# Tokens
A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
# Authenticating to the API
By default, read operations will be available without authentication. In this case, a token may be included in the request, but is not necessary.
```
$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
{
"count": 10,
"next": null,
"previous": null,
"results": [...]
}
```
However, if the [`LOGIN_REQUIRED`](../configuration/optional-settings/#login_required) configuration setting has been set to `True`, all requests must be authenticated.
```
$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
{
"detail": "Authentication credentials were not provided."
}
```
To authenticate to the API, set the HTTP `Authorization` header to the string `Token ` (note the trailing space) followed by the token key.
```
$ curl -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
{
"count": 10,
"next": null,
"previous": null,
"results": [...]
}
```
Additionally, the browsable interface to the API (which can be seen by navigating to the API root `/api/` in a web browser) will attempt to authenticate requests using the same cookie that the normal NetBox front end uses. Thus, if you have logged into NetBox, you will be logged into the browsable API as well.

138
docs/api/examples.md Normal file
View File

@@ -0,0 +1,138 @@
# API Examples
Supported HTTP methods:
* `GET`: Retrieve an object or list of objects
* `POST`: Create a new object
* `PUT`: Update an existing object
* `DELETE`: Delete an existing object
To authenticate a request, attach your token in an `Authorization` header:
```
curl -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0"
```
### Retrieving a list of sites
Send a `GET` request to the object list endpoint. The response contains a paginated list of JSON objects.
```
$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/
{
"count": 14,
"next": null,
"previous": null,
"results": [
{
"id": 6,
"name": "Corporate HQ",
"slug": "corporate-hq",
"region": null,
"tenant": null,
"facility": "",
"asn": null,
"physical_address": "742 Evergreen Terrace, Springfield, USA",
"shipping_address": "",
"contact_name": "",
"contact_phone": "",
"contact_email": "",
"comments": "",
"custom_fields": {},
"count_prefixes": 108,
"count_vlans": 46,
"count_racks": 8,
"count_devices": 254,
"count_circuits": 6
},
...
]
}
```
### Retrieving a single site by ID
Send a `GET` request to the object detail endpoint. The response contains a single JSON object.
```
$ curl -H "Accept: application/json; indent=4" http://localhost/api/dcim/sites/6/
{
"id": 6,
"name": "Corporate HQ",
"slug": "corporate-hq",
"region": null,
"tenant": null,
"facility": "",
"asn": null,
"physical_address": "742 Evergreen Terrace, Springfield, USA",
"shipping_address": "",
"contact_name": "",
"contact_phone": "",
"contact_email": "",
"comments": "",
"custom_fields": {},
"count_prefixes": 108,
"count_vlans": 46,
"count_racks": 8,
"count_devices": 254,
"count_circuits": 6
}
```
### Creating a new site
Send a `POST` request to the site list endpoint with token authentication and JSON-formatted data. Only mandatory fields are required.
```
$ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/ --data '{"name": "My New Site", "slug": "my-new-site"}'
{
"id": 16,
"name": "My New Site",
"slug": "my-new-site",
"region": null,
"tenant": null,
"facility": "",
"asn": null,
"physical_address": "",
"shipping_address": "",
"contact_name": "",
"contact_phone": "",
"contact_email": "",
"comments": ""
}
```
### Modify an existing site
Make an authenticated `PUT` request to the site detail endpoint. As with a create (POST) request, all mandatory fields must be included.
```
$ curl -X PUT -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/ --data '{"name": "Renamed Site", "slug": "renamed-site"}'
```
### Delete an existing site
Send an authenticated `DELETE` request to the site detail endpoint.
```
$ curl -v X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/sites/16/
* Connected to localhost (127.0.0.1) port 8000 (#0)
> DELETE /api/dcim/sites/16/ HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:8000
> Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
> Content-Type: application/json
> Accept: application/json; indent=4
>
* HTTP 1.0, assume close after body
< HTTP/1.0 204 No Content
< Date: Mon, 20 Mar 2017 16:13:08 GMT
< Server: WSGIServer/0.1 Python/2.7.6
< Vary: Accept, Cookie
< X-Frame-Options: SAMEORIGIN
< Allow: GET, PUT, PATCH, DELETE, OPTIONS
<
* Closing connection 0
```
The response to a successfull `DELETE` request will have code 204 (No Content); the body of the response will be empty.

138
docs/api/overview.md Normal file
View File

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

View File

@@ -0,0 +1,136 @@
As with most other objects, the NetBox API can be used to create, modify, and delete secrets. However, additional steps are needed to encrypt or decrypt secret data.
# Generating a Session Key
In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../data-model/secrets/#user-keys). The private key must be POSTed with the name `private_key`.
```
$ curl -X POST http://localhost:8000/api/secrets/get-session-key/ \
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
-H "Accept: application/json; indent=4" \
--data-urlencode "private_key@<filename>"
{
"session_key": "dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
}
```
!!! note
To read the private key from a file, use the convention above. Alternatively, the private key can be read from an environment variable using `--data-urlencode "private_key=$PRIVATE_KEY"`.
The request uses your private key to unlock your stored copy of the master key and generate a session key which can be attached in the `X-Session-Key` header of future API requests.
# Retrieving Secrets
A session key is not needed to retrieve unencrypted secrets: The secret is returned like any normal object with its `plaintext` field set to null.
```
$ curl http://localhost:8000/api/secrets/secrets/2587/ \
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
-H "Accept: application/json; indent=4"
{
"id": 2587,
"device": {
"id": 1827,
"url": "http://localhost:8000/api/dcim/devices/1827/",
"name": "MyTestDevice",
"display_name": "MyTestDevice"
},
"role": {
"id": 1,
"url": "http://localhost:8000/api/secrets/secret-roles/1/",
"name": "Login Credentials",
"slug": "login-creds"
},
"name": "admin",
"plaintext": null,
"hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=",
"created": "2017-03-21",
"last_updated": "2017-03-21T19:28:44.265582Z"
}
```
To decrypt a secret, we must include our session key in the `X-Session-Key` header:
```
$ curl http://localhost:8000/api/secrets/secrets/2587/ \
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
{
"id": 2587,
"device": {
"id": 1827,
"url": "http://localhost:8000/api/dcim/devices/1827/",
"name": "MyTestDevice",
"display_name": "MyTestDevice"
},
"role": {
"id": 1,
"url": "http://localhost:8000/api/secrets/secret-roles/1/",
"name": "Login Credentials",
"slug": "login-creds"
},
"name": "admin",
"plaintext": "foobar",
"hash": "pbkdf2_sha256$1000$G6mMFe4FetZQ$f+0itZbAoUqW5pd8+NH8W5rdp/2QNLIBb+LGdt4OSKA=",
"created": "2017-03-21",
"last_updated": "2017-03-21T19:28:44.265582Z"
}
```
Lists of secrets can be decrypted in this manner as well:
```
$ curl http://localhost:8000/api/secrets/secrets/?limit=3 \
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk="
{
"count": 3482,
"next": "http://localhost:8000/api/secrets/secrets/?limit=3&offset=3",
"previous": null,
"results": [
{
"id": 2587,
...
"plaintext": "foobar",
...
},
{
"id": 2588,
...
"plaintext": "MyP@ssw0rd!",
...
},
{
"id": 2589,
...
"plaintext": "AnotherSecret!",
...
},
]
}
```
# Creating Secrets
Session keys are also used to decrypt new or modified secrets. This is done by setting the `plaintext` field of the submitted object:
```
$ curl -X POST http://localhost:8000/api/secrets/secrets/ \
-H "Content-Type: application/json" \
-H "Authorization: Token c639d619ecbeb1f3055c4141ba6870e20572edd7" \
-H "Accept: application/json; indent=4" \
-H "X-Session-Key: dyEnxlc9lnGzaOAV1dV/xqYPV63njIbdZYOgnAlGPHk=" \
--data '{"device": 1827, "role": 1, "name": "backup", "plaintext": "Drowssap1"}'
{
"id": 2590,
"device": 1827,
"role": 1,
"name": "backup",
"plaintext": "Drowssap1"
}
```
!!! note
Don't forget to include the `Content-Type: application/json` header when making a POST request.

View File

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

View File

@@ -2,7 +2,7 @@ The circuits component of NetBox deals with the management of long-haul Internet
# Providers
A provider is any entity which provides some form of connectivity. This obviously includes carriers which offer Internet and private transit service. However, it might also include Internet exchange (IX) points and even organizations with whom you peer directly.
A provider is any entity which provides some form of connectivity. While this obviously includes carriers which offer Internet and private transit service, it might also include Internet exchange (IX) points and even organizations with whom you peer directly.
Each provider may be assigned an autonomous system number (ASN), an account number, and contact information.
@@ -14,7 +14,7 @@ A circuit represents a single physical data link connecting two endpoints. Each
### Circuit Types
Circuits are classified by type. For example:
Circuits are classified by type. For example, you might define circuit types for:
* Internet transit
* Out-of-band connectivity
@@ -27,7 +27,7 @@ Circuit types are fully customizable.
A circuit may have one or two terminations, annotated as the "A" and "Z" sides of the circuit. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites.
Each circuit termination can be tied to a site, or to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details.
Each circuit termination is tied to a site, and optionally to a specific device and interface within that site. Each termination can be assigned a separate downstream and upstream speed independent from one another. Fields are also available to track cross-connect and patch panel details.
!!! note
A circuit represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit.

View File

@@ -2,61 +2,72 @@ Data center infrastructure management (DCIM) entails all physical assets: sites,
# Sites
How you define sites will depend on the nature of your organization, but typically a site will equate a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities.
How you choose to use sites will depend on the nature of your organization, but typically a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities.
Sites can be assigned an optional facility ID to identify the actual facility housing colocated equipment.
Sites can be assigned an optional facility ID to identify the actual facility housing colocated equipment, and an Autonomous System (AS) number.
### Regions
Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned.
---
# Racks
Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units* (U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted.
The rack model represents a physical two- or four-post equipment rack in which equipment is mounted. Each rack is assigned to a site. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U, but NetBox allows you to define racks of arbitrary height. Each rack has two faces (front and rear) on which devices can be mounted.
Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, M204.313) whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number.
Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number.
The available rack types include 2- and 4-post frames, 4-post cabinet, and wall-mounted frame and cabinet. Rail-to-rail width may be 19 or 23 inches.
### Rack Groups
Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site is a campus, each group might be a building. If each site is a building, each rack group might be a floor or room.
Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room.
Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not supported.
### Rack Roles
Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices.
Each rack can optionally be assigned a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable.
### Rack Space Reservations
Users can reserve units within a rack for future use. Multiple non-contiguous rack units can be associated with a single reservation (but reservations cannot span multiple racks).
---
# Device Types
A device type represents a particular manufacturer and model of equipment. Device types describe the physical attributes of a device (rack height and depth), its class (e.g. console server, PDU, etc.), and its individual components (console, power, and data).
A device type represents a particular hardware model that exists in the real world. Device types describe the physical attributes of a device (rack height and depth), its class (e.g. console server, PDU, etc.), and its individual components (console, power, and data).
Device types are instantiated as devices installed within racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple devices of this type named "switch1," "switch2," and so on. Each device will inherit the components (such as interfaces) of its device type.
### Manufacturers
Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. Manufacturers are used to group different models of device.
Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. The model number of a device type must be unique to its manufacturer.
### Component Templates
Each device type is assigned a number of component templates which describe the console, power, and data ports a device has. These are:
Each device type is assigned a number of component templates which define the physical interfaces a device has. These are:
* Console port templates
* Console server port templates
* Power port templates
* Power outlet templates
* Interface templates
* Device bay templates
* Console ports
* Console server ports
* Power ports
* Power outlets
* Interfaces
* Device bays
Whenever a new device is created, it is automatically assigned console, power, and interface components per the templates assigned to its device type. For example, suppose your network employs Juniper EX4300-48T switches. You would create a device type with a model name "EX4300-48T" and assign it to the manufacturer "Juniper." You might then also create the following templates for it:
Whenever a new device is created, it is automatically assigned components per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates:
* One template for a console port ("Console")
* Two templates for power ports ("PSU0" and "PSU1")
* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47")
* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3")
Once you've done this, every new device that you create as an instance of this type will automatically be assigned each of the components listed above.
Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above.
Note that assignment of components from templates occurs only at the time of device creation: If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components of existing devices individually.
!!! note
Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components of existing devices individually.
---
@@ -64,23 +75,26 @@ Note that assignment of components from templates occurs only at the time of dev
Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined.
When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 shows as being mounted in U8.
When assigning a multi-U device to a rack, it is considered to be mounted in the lowest-numbered rack unit which it occupies. For example, a 3U device which occupies U8 through U10 shows as being mounted in U8. This logic applies to racks with both ascending and descending unit numbering.
A device is said to be "full depth" if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede air flow.
### Roles
NetBox allows for the definition of arbitrary device roles by which devices can be organized. For example, you might create roles for core switches, distribution switches, and access switches. In the interest of simplicity, device can only belong to one device role.
NetBox allows for the definition of arbitrary device roles by which devices can be organized. For example, you might create roles for core switches, distribution switches, and access switches. In the interest of simplicity, a device can belong to only one role.
### Platforms
A device's platform is used to denote the type of software running on it. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
The assignment of platforms to devices is an entirely optional feature, and may be disregarded if not desired.
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
### Modules
### Inventory Items
A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each module can optionally be assigned to a manufacturer.
Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each item can optionally be assigned a manufacturer.
!!! note
Prior to version 2.0, inventory items were called modules.
### Components
@@ -93,10 +107,8 @@ There are six types of device components which comprise all of the interconnecti
* Interfaces
* Device bays
Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.)
Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.) Each type of connection can be classified as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed.
Each type of connection can be classified as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed. In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description.
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
Note that child devices differ from modules in that they are still treated as independent devices, with their own console/power/data components, modules, and IP addresses. Modules, on the other hand, are parts within a device, such as a hard disk or power supply.

View File

@@ -2,7 +2,7 @@ This section entails features of NetBox which are not crucial to its primary fun
# Custom Fields
Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address` and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
Each object in NetBox is represented in the database as a discrete table, and each attribute of an object exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows.
However, some users might want to associate with objects attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number pointing to the support ticket that was opened to have it installed. This is certainly a legitimate use for NetBox, but it's perhaps not a common enough need to warrant expanding the internal data schema. Instead, you can create a custom field to hold this data.
@@ -33,7 +33,15 @@ NetBox allows users to define custom templates that can be used when exporting o
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop.
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
```
{% for rack in queryset %}
Rack: {{ rack.name }}
Site: {{ rack.site.name }}
Height: {{ rack.u_height }}U
{% endfor %}
```
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
@@ -44,10 +52,10 @@ A MIME type and file extension can optionally be defined for each export templat
Here's an example device export template that will generate a simple Nagios configuration from a list of devices.
```
{% for d in queryset %}{% if d.status and d.primary_ip %}define host{
{% for device in queryset %}{% if device.status and device.primary_ip %}define host{
use generic-switch
host_name {{ d.name }}
address {{ d.primary_ip.address.ip }}
host_name {{ device.name }}
address {{ device.primary_ip.address.ip }}
}
{% endif %}{% endfor %}
```
@@ -74,19 +82,35 @@ define host{
# Graphs
NetBox does not generate graphs itself. This feature allows you to embed contextual graphs from an external resources inside certain NetBox views. Each embedded graph must be defined with the following parameters:
NetBox does not have the ability to generate graphs natively, but this feature allows you to embed contextual graphs from an external resources (such as a monitoring system) inside the site, provider, and interface views. Each embedded graph must be defined with the following parameters:
* **Type:** Interface, provider, or site. This determines where the graph will be displayed.
* **Type:** Site, provider, or interface. This determines in which view the graph will be displayed.
* **Weight:** Determines the order in which graphs are displayed (lower weights are displayed first). Graphs with equal weights will be ordered alphabetically by name.
* **Name:** The title to display above the graph.
* **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`.
* **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`.
## Examples
You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this:
```
https://my.nms.local/graphs/?node={{ obj.device.name }}&interface={{ obj.name }}&duration=5m
```
You can define several graphs to provide multiple contexts when viewing an object. For example:
```
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
https://my.nms.local/graphs/?type=throughput&node={{ obj.device.name }}&interface={{ obj.name }}&duration=24h
https://my.nms.local/graphs/?type=errors&node={{ obj.device.name }}&interface={{ obj.name }}&duration=60m
```
# Topology Maps
NetBox can generate simple topology maps from the physical network connections recorded in its database. First, you'll need to create a topology map definition under the admin UI at Extras > Topology Maps.
Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend connectivity).
Each topology map is associated with a site. A site can have multiple topology maps, which might each illustrate a different aspect of its infrastructure (for example, production versus backend infrastructure).
To define the scope of a topology map, decide which devices you want to include. The map will only include interface connections with both points terminated on an included device. Specify the devices to include in the **device patterns** field by entering a list of [regular expressions](https://en.wikipedia.org/wiki/Regular_expression) matching device names. For example, if you wanted to include "mgmt-switch1" through "mgmt-switch99", you might use the regex `mgmt-switch\d+`.
@@ -99,3 +123,10 @@ access-switch\d+,oob-switch\d+
```
Note that you can combine multiple regexes onto one line using semicolons. The order in which regexes are listed on a line is significant: devices matching the first regex will be rendered first, and subsequent groups will be rendered to the right of those.
# Image Attachments
Certain objects within NetBox (namely sites, racks, and devices) can have photos or other images attached to them. (Note that _only_ image files are supported.) Each attachment may optionally be assigned a name; if omitted, the attachment will be represented by its file name.
!!! note
If you experience a server error while attempting to upload an image attachment, verify that the system user NetBox runs as has write permission to the media root directory (`netbox/media/`).

View File

@@ -6,11 +6,14 @@ A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain
Each VRF is assigned a name and a unique route distinguisher (RD). VRFs are an optional feature of NetBox: Any IP prefix or address not assigned to a VRF is said to belong to the "global" table.
!!! note
By default, NetBox allows for overlapping IP space both in the global table and within each VRF. Unique space enforcement can be toggled per-VRF as well as in the global table using the `ENFORCE_GLOBAL_UNIQUE` configuration setting.
---
# Aggregates
IPv4 address space is organized as a hierarchy, with more-specific (smaller) prefix arranged as child nodes under less-specific (larger) prefixes. For example:
IP address space is organized as a hierarchy, with more-specific (smaller) prefixes arranged as child nodes under less-specific (larger) prefixes. For example:
* 10.0.0.0/8
* 10.1.0.0/16
@@ -18,23 +21,23 @@ IPv4 address space is organized as a hierarchy, with more-specific (smaller) pre
The root of the IPv4 hierarchy is 0.0.0.0/0, which encompasses all possible IPv4 addresses (and similarly, ::/0 for IPv6). However, even the largest organizations use only a small fraction of the global address space. Therefore, it makes sense to track in NetBox only the address space which is of interest to your organization.
Aggregates serve as arbitrary top-level nodes in the IP space hierarchy. They allow you to easily construct your IP scheme without any clutter of unused address space. For instance, most organizations utilize some portion of the RFC 1918 private IPv4 space. So, you might define three aggregates for this space:
Aggregates serve as arbitrary top-level nodes in the IP space hierarchy. They allow you to easily construct your IP scheme without any clutter of unused address space. For instance, most organizations utilize some portion of the private IPv4 space set aside in RFC 1918. So, you might define three aggregates for this space:
* 10.0.0.0/8
* 172.16.0.0/12
* 192.168.0.0/16
Additionally, you might define an aggregate for each large swath of public IPv4 space your organization uses. You'd also create aggregates for both globally routable and unique local IPv6 space.
Additionally, you might define an aggregate for each large swath of public IPv4 space your organization uses. You'd also create aggregates for both globally routable and unique local IPv6 space. (Most organizations will not have a need to track IPv6 link local space.)
Any prefixes you create in NetBox (discussed below) will be automatically organized under their respective aggregates. Any space within an aggregate which is not covered by an existing prefix will be annotated as available for allocation.
Prefixes you create in NetBox (discussed below) will be automatically organized under their respective aggregates. Any space within an aggregate which is not covered by an existing prefix will be annotated as available for allocation. Total utilization for each aggregate is displayed in the aggregates list.
Aggregates cannot overlap with one another; they can only exist in parallel. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix.
Aggregates cannot overlap with one another; they can only exist in parallel. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a prefix and automatically grouped under 10.0.0.0/8.
### RIRs
Regional Internet Registries (RIRs) are responsible for the allocation of global address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for private or internal use only, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space.
Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own).
Each aggregate must be assigned to one RIR. You are free to define whichever RIRs you choose (or create your own). Each RIR can be annotated as representing only private space.
---
@@ -44,7 +47,7 @@ A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 19
Each prefix may be assigned to one VRF; prefixes not assigned to a VRF are assigned to the "global" table. Prefixes are also organized under their respective aggregates, irrespective of VRF assignment.
A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefixes assigned to it. This can be helpful is replicating real-world IP assignments. Each prefix may also be assigned a short description.
A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefixes assigned to it. Each prefix may also be assigned a short description.
### Statuses
@@ -52,7 +55,7 @@ Each prefix is assigned an operational status. This is one of the following:
* Container - A summary of child prefixes
* Active - Provisioned and in use
* Reserved - Earmarked for future use
* Reserved - Designated for future use
* Deprecated - No longer in use
### Roles
@@ -65,30 +68,32 @@ Whereas a status describes a prefix's operational state, a role describes its fu
* Lab
* Out-of-band
Role assignment is optional and you are free to create as many as you'd like.
Role assignment is optional and roles are fully customizable.
---
# IP Addresses
An IP address comprises a single address (either IPv4 or IPv6) and its mask. Its mask should match exactly how the IP address is configured on an interface in the real world.
An IP address comprises a single address (either IPv4 or IPv6) and its subnet mask. Its mask should match exactly how the IP address is configured on an interface in the real world.
Like prefixes, an IP address can optionally be assigned to a VRF (or it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs. Each IP address can also be assigned a short description.
Each IP address can optionally be assigned to a device's interface; an interface may have multiple IP addresses assigned to it. Further, each device may have one of its interface IPs designated as its primary IP address.
An IP address can be assigned to a device's interface; an interface may have multiple IP addresses assigned to it. Further, each device may have one of its interface IPs designated as its primary IP address (for both IPv4 and IPv6).
One IP address can be designated as the network address translation (NAT) IP address for exactly one other IP address. This is useful primarily is denoting the public address for a private internal IP. Tracking one-to-many NAT (or PAT) assignments is not currently supported.
One IP address can be designated as the network address translation (NAT) IP address for exactly one other IP address. This is useful primarily to denote the public address for a private internal IP. Tracking one-to-many NAT (or PAT) assignments is not supported.
---
# VLANs
A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice.
A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). Each VLAN may be assigned to a site and/or VLAN group. Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role, and may include a short description.
Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role.
### VLAN Groups
VLAN groups can be employed for administrative organization within NetBox. Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs, including within a site.
---
# Services
A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, SSH (TCP/22). A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any IP address.)
A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)

View File

@@ -24,11 +24,11 @@ Roles are also used to control access to secrets. Each role is assigned an arbit
Each user within NetBox can associate his or her account with an RSA public key. If activated by an administrator, this user key will contain a unique, encrypted copy of the AES master key needed to retrieve secret data.
User keys may be created by users individually, however they are of no use until they have been activated by a user who already has access to retrieve secret data.
User keys may be created by users individually, however they are of no use until they have been activated by a user who already possesses an active user key.
## Creating the First User Key
When NetBox is first installed, it contains no encryption keys. Before it can store secrets, a user (typically the super user) must create a user key. This can be done by navigating to Profile > User Key.
When NetBox is first installed, it contains no encryption keys. Before it can store secrets, a user (typically the superuser) must create a user key. This can be done by navigating to Profile > User Key.
To create a user key, you can either generate a new RSA key pair, or upload the public key belonging to a pair you already have. If generating a new key pair, **you must save the private key** locally before saving your new user key. Once your user key has been created, its public key will be displayed under your profile.

View File

@@ -1,10 +1,8 @@
NetBox supports the concept of individual tenants within its parent organization. Typically, these are used to represent individual customers or internal departments.
NetBox supports the assignment of resources to tenant organizations. Typically, these are used to represent individual customers of or internal departments within the organization using NetBox.
# Tenants
A tenant represents a discrete organization. Certain resources within NetBox can be assigned to a tenant. This makes it very convenient to track which resources are assigned to which customers, for instance.
The following objects can be assigned to tenants:
A tenant represents a discrete organization. The following objects can be assigned to tenants:
* Sites
* Racks

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

@@ -2,12 +2,32 @@
**Debian/Ubuntu**
Python 3:
```no-highlight
# apt-get install -y python3 python3-dev python3-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
# 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
```
**CentOS/RHEL**
Python 3:
```no-highlight
# yum install -y epel-release
# 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:
```no-highlight
# yum install -y epel-release
# yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel
@@ -66,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
```
@@ -155,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

@@ -27,6 +27,12 @@ If you followed the original installation guide to set up gunicorn, be sure to c
# cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py
```
Copy the LDAP configuration if using LDAP:
```no-highlight
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
```
## Option B: Clone the Git Repository (latest master release)
This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:

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/;

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'
@@ -19,7 +18,11 @@ pages:
- 'Secrets': 'data-model/secrets.md'
- 'Tenancy': 'data-model/tenancy.md'
- 'Extras': 'data-model/extras.md'
- 'API Integration': 'api-integration.md'
- 'API':
- 'Overview': 'api/overview.md'
- 'Authentication': 'api/authentication.md'
- 'Working with Secrets': 'api/working-with-secrets.md'
- 'Examples': 'api/examples.md'
markdown_extensions:
- admonition:

View File

@@ -0,0 +1 @@
default_app_config = 'circuits.apps.CircuitsConfig'

View File

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

View File

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

View File

@@ -1,25 +1,26 @@
from django.conf.urls import url
from rest_framework import routers
from extras.models import GRAPH_TYPE_PROVIDER
from extras.api.views import GraphListView
from .views import *
from . import views
urlpatterns = [
class CircuitsRootView(routers.APIRootView):
"""
Circuits API root view
"""
def get_view_name(self):
return 'Circuits'
# Providers
url(r'^providers/$', ProviderListView.as_view(), name='provider_list'),
url(r'^providers/(?P<pk>\d+)/$', ProviderDetailView.as_view(), name='provider_detail'),
url(r'^providers/(?P<pk>\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER},
name='provider_graphs'),
# Circuit types
url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'),
url(r'^circuit-types/(?P<pk>\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'),
router = routers.DefaultRouter()
router.APIRootView = CircuitsRootView
# Circuits
url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'),
url(r'^circuits/(?P<pk>\d+)/$', CircuitDetailView.as_view(), name='circuit_detail'),
# Providers
router.register(r'providers', views.ProviderViewSet)
]
# Circuits
router.register(r'circuit-types', views.CircuitTypeViewSet)
router.register(r'circuits', views.CircuitViewSet)
router.register(r'circuit-terminations', views.CircuitTerminationViewSet)
app_name = 'circuits-api'
urlpatterns = router.urls

View File

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

9
netbox/circuits/apps.py Normal file
View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class CircuitsConfig(AppConfig):
name = "circuits"
verbose_name = "Circuits"
def ready(self):
import circuits.signals

View File

@@ -5,14 +5,14 @@ from django.db.models import Q
from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from .models import Provider, Circuit, CircuitType
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import Provider, Circuit, CircuitTermination, CircuitType
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -31,7 +31,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Provider
fields = ['name', 'account', 'asn']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(account__icontains=value) |
@@ -40,8 +42,9 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
provider_id = django_filters.ModelMultipleChoiceFilter(
@@ -93,7 +96,9 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Circuit
fields = ['install_date']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(cid__icontains=value) |
Q(terminations__xconnect_id__icontains=value) |
@@ -101,3 +106,15 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
Q(description__icontains=value) |
Q(comments__icontains=value)
).distinct()
class CircuitTerminationFilter(django_filters.FilterSet):
circuit_id = django_filters.ModelMultipleChoiceFilter(
name='circuit',
queryset=Circuit.objects.all(),
label='Circuit',
)
class Meta:
model = CircuitTermination
fields = ['term_side', 'site']

View File

@@ -1,12 +1,13 @@
from django import forms
from django.db.models import Count
from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
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
@@ -62,7 +63,9 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Provider
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug')
asn = forms.IntegerField(required=False, label='ASN')
#
@@ -81,12 +84,15 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
# Circuits
#
class CircuitForm(BootstrapMixin, CustomFieldForm):
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
comments = CommentField()
class Meta:
model = Circuit
fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
fields = [
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
'comments',
]
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
@@ -126,34 +132,79 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Circuit
type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
null_option=(0, 'None'))
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
to_field_name='slug')
q = forms.CharField(required=False, label='Search')
type = FilterChoiceField(
queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug'
)
provider = FilterChoiceField(
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug'
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
to_field_name='slug',
null_option=(0, 'None')
)
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
to_field_name='slug'
)
#
# Circuit terminations
#
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
display_field='display_name', 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')
class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
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,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{site}}',
attrs={'filter-for': 'device', 'nullable': 'true'}
)
)
device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'site', 'rack': 'rack'},
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
livesearch = forms.CharField(
required=False,
label='Device',
widget=Livesearch(
query_key='q',
query_url='dcim-api:device-list',
field_to_update='device'
)
)
interface = ChainedModelChoiceField(
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(
api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
disabled_indicator='is_connected'
)
)
interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
disabled_indicator='is_connected'))
class Meta:
model = CircuitTermination
@@ -170,45 +221,17 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
# Initialize helper selectors
instance = kwargs.get('instance')
if instance and instance.interface is not None:
initial = kwargs.get('initial', {})
initial['rack'] = instance.interface.device.rack
initial['device'] = instance.interface.device
kwargs['initial'] = initial
super(CircuitTerminationForm, self).__init__(*args, **kwargs)
# If an interface has been assigned, initialize rack and device
if self.instance.interface:
self.initial['rack'] = self.instance.interface.device.rack
self.initial['device'] = self.instance.interface.device
# Limit rack choices
if self.is_bound:
self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site'])
elif self.initial.get('site'):
self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site'])
else:
self.fields['rack'].choices = []
# Limit device choices
if self.is_bound and self.data.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack'])
elif self.initial.get('rack'):
self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack'])
else:
self.fields['device'].choices = []
# Limit interface choices
if self.is_bound and self.data.get('device'):
interfaces = Interface.objects.filter(device=self.data['device'])\
.exclude(form_factor=IFACE_FF_VIRTUAL).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=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
'connected_as_b')
self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
else:
interfaces = []
# Mark connected interfaces as disabled
self.fields['interface'].choices = [
(iface.id, {
'label': iface.name,
'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
}) for iface in interfaces
(iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in self.fields['interface'].queryset
]

View File

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

View File

@@ -1,6 +1,7 @@
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
from extras.models import CustomFieldModel, CustomFieldValue
@@ -33,6 +34,7 @@ def humanize_speed(speed):
return '{} Kbps'.format(speed)
@python_2_unicode_compatible
class Provider(CreatedUpdatedModel, CustomFieldModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -51,7 +53,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -67,6 +69,7 @@ class Provider(CreatedUpdatedModel, CustomFieldModel):
])
@python_2_unicode_compatible
class CircuitType(models.Model):
"""
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
@@ -78,13 +81,14 @@ class CircuitType(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
@python_2_unicode_compatible
class Circuit(CreatedUpdatedModel, CustomFieldModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
@@ -105,7 +109,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
ordering = ['provider', 'cid']
unique_together = ['provider', 'cid']
def __unicode__(self):
def __str__(self):
return u'{} {}'.format(self.provider, self.cid)
def get_absolute_url(self):
@@ -141,14 +145,19 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
commit_rate_human.admin_order_field = 'commit_rate'
@python_2_unicode_compatible
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)')
@@ -156,7 +165,7 @@ class CircuitTermination(models.Model):
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']
def __unicode__(self):
def __str__(self):
return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
def get_peer_termination(self):

View File

@@ -0,0 +1,13 @@
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import timezone
from .models import Circuit, CircuitTermination
@receiver((post_save, post_delete), sender=CircuitTermination)
def update_circuit(instance, **kwargs):
"""
When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
"""
Circuit.objects.filter(pk=instance.circuit_id).update(last_updated=timezone.now())

View File

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

View File

View File

@@ -0,0 +1,329 @@
from rest_framework import status
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from django.urls import reverse
from dcim.models import Site
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
from users.models import Token
from utilities.tests import HttpStatusMixin
class ProviderTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
self.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3')
def test_get_provider(self):
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.provider1.name)
def test_get_provider_graphs(self):
self.graph1 = Graph.objects.create(
type=GRAPH_TYPE_PROVIDER, name='Test Graph 1',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=GRAPH_TYPE_PROVIDER, name='Test Graph 2',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=GRAPH_TYPE_PROVIDER, name='Test Graph 3',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'
)
url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=test-provider-1&foo=1')
def test_list_providers(self):
url = reverse('circuits-api:provider-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_provider(self):
data = {
'name': 'Test Provider 4',
'slug': 'test-provider-4',
}
url = reverse('circuits-api:provider-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Provider.objects.count(), 4)
provider4 = Provider.objects.get(pk=response.data['id'])
self.assertEqual(provider4.name, data['name'])
self.assertEqual(provider4.slug, data['slug'])
def test_update_provider(self):
data = {
'name': 'Test Provider X',
'slug': 'test-provider-x',
}
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Provider.objects.count(), 3)
provider1 = Provider.objects.get(pk=response.data['id'])
self.assertEqual(provider1.name, data['name'])
self.assertEqual(provider1.slug, data['slug'])
def test_delete_provider(self):
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Provider.objects.count(), 2)
class CircuitTypeTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
self.circuittype3 = CircuitType.objects.create(name='Test Circuit Type 3', slug='test-circuit-type-3')
def test_get_circuittype(self):
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.circuittype1.name)
def test_list_circuittypes(self):
url = reverse('circuits-api:circuittype-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_circuittype(self):
data = {
'name': 'Test Circuit Type 4',
'slug': 'test-circuit-type-4',
}
url = reverse('circuits-api:circuittype-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitType.objects.count(), 4)
circuittype4 = CircuitType.objects.get(pk=response.data['id'])
self.assertEqual(circuittype4.name, data['name'])
self.assertEqual(circuittype4.slug, data['slug'])
def test_update_circuittype(self):
data = {
'name': 'Test Circuit Type X',
'slug': 'test-circuit-type-x',
}
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitType.objects.count(), 3)
circuittype1 = CircuitType.objects.get(pk=response.data['id'])
self.assertEqual(circuittype1.name, data['name'])
self.assertEqual(circuittype1.slug, data['slug'])
def test_delete_circuittype(self):
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(CircuitType.objects.count(), 2)
class CircuitTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=self.provider1, type=self.circuittype1)
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=self.provider1, type=self.circuittype1)
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=self.provider1, type=self.circuittype1)
def test_get_circuit(self):
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['cid'], self.circuit1.cid)
def test_list_circuits(self):
url = reverse('circuits-api:circuit-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_circuit(self):
data = {
'cid': 'TEST0004',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
}
url = reverse('circuits-api:circuit-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Circuit.objects.count(), 4)
circuit4 = Circuit.objects.get(pk=response.data['id'])
self.assertEqual(circuit4.cid, data['cid'])
self.assertEqual(circuit4.provider_id, data['provider'])
self.assertEqual(circuit4.type_id, data['type'])
def test_update_circuit(self):
data = {
'cid': 'TEST000X',
'provider': self.provider2.pk,
'type': self.circuittype2.pk,
}
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Circuit.objects.count(), 3)
circuit1 = Circuit.objects.get(pk=response.data['id'])
self.assertEqual(circuit1.cid, data['cid'])
self.assertEqual(circuit1.provider_id, data['provider'])
self.assertEqual(circuit1.type_id, data['type'])
def test_delete_circuit(self):
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Circuit.objects.count(), 2)
class CircuitTerminationTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
provider = Provider.objects.create(name='Test Provider', slug='test-provider')
circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype)
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
self.circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
)
self.circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
)
self.circuittermination3 = CircuitTermination.objects.create(
circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
)
def test_get_circuittermination(self):
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['id'], self.circuittermination1.pk)
def test_list_circuitterminations(self):
url = reverse('circuits-api:circuittermination-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_circuittermination(self):
data = {
'circuit': self.circuit1.pk,
'term_side': TERM_SIDE_Z,
'site': self.site2.pk,
'port_speed': 1000000,
}
url = reverse('circuits-api:circuittermination-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitTermination.objects.count(), 4)
circuittermination4 = CircuitTermination.objects.get(pk=response.data['id'])
self.assertEqual(circuittermination4.circuit_id, data['circuit'])
self.assertEqual(circuittermination4.term_side, data['term_side'])
self.assertEqual(circuittermination4.site_id, data['site'])
self.assertEqual(circuittermination4.port_speed, data['port_speed'])
def test_update_circuittermination(self):
data = {
'circuit': self.circuit1.pk,
'term_side': TERM_SIDE_Z,
'site': self.site2.pk,
'port_speed': 1000000,
}
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitTermination.objects.count(), 3)
circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
self.assertEqual(circuittermination1.circuit_id, data['circuit'])
self.assertEqual(circuittermination1.term_side, data['term_side'])
self.assertEqual(circuittermination1.site_id, data['site'])
self.assertEqual(circuittermination1.port_speed, data['port_speed'])
def test_delete_circuittermination(self):
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(CircuitTermination.objects.count(), 2)

View File

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

View File

@@ -1,10 +1,10 @@
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.forms import ConfirmationForm
@@ -25,14 +25,14 @@ class ProviderListView(ObjectListView):
filter = filters.ProviderFilter
filter_form = forms.ProviderFilterForm
table = tables.ProviderTable
edit_permissions = ['circuits.change_provider', 'circuits.delete_provider']
template_name = 'circuits/provider_list.html'
def provider(request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider)
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', {
@@ -47,7 +47,7 @@ class ProviderEditView(PermissionRequiredMixin, ObjectEditView):
model = Provider
form_class = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
obj_list_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -61,21 +61,23 @@ class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.ProviderImportForm
table = tables.ProviderTable
template_name = 'circuits/provider_import.html'
obj_list_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_provider'
cls = Provider
filter = filters.ProviderFilter
form = forms.ProviderBulkEditForm
template_name = 'circuits/provider_bulk_edit.html'
default_redirect_url = 'circuits:provider_list'
default_return_url = 'circuits:provider_list'
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
cls = Provider
default_redirect_url = 'circuits:provider_list'
filter = filters.ProviderFilter
default_return_url = 'circuits:provider_list'
#
@@ -85,7 +87,6 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class CircuitTypeListView(ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
edit_permissions = ['circuits.change_circuittype', 'circuits.delete_circuittype']
template_name = 'circuits/circuittype_list.html'
@@ -94,14 +95,14 @@ 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')
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = CircuitType
default_redirect_url = 'circuits:circuittype_list'
default_return_url = 'circuits:circuittype_list'
#
@@ -113,15 +114,22 @@ class CircuitListView(ObjectListView):
filter = filters.CircuitFilter
filter_form = forms.CircuitFilterForm
table = tables.CircuitTable
edit_permissions = ['circuits.change_circuit', 'circuits.delete_circuit']
template_name = 'circuits/circuit_list.html'
def circuit(request, pk):
circuit = get_object_or_404(Circuit, pk=pk)
termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
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,
@@ -134,9 +142,8 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.change_circuit'
model = Circuit
form_class = forms.CircuitForm
fields_initial = ['provider']
template_name = 'circuits/circuit_edit.html'
obj_list_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
@@ -150,21 +157,23 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
form = forms.CircuitImportForm
table = tables.CircuitTable
template_name = 'circuits/circuit_import.html'
obj_list_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
cls = Circuit
filter = filters.CircuitFilter
form = forms.CircuitBulkEditForm
template_name = 'circuits/circuit_bulk_edit.html'
default_redirect_url = 'circuits:circuit_list'
default_return_url = 'circuits:circuit_list'
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
cls = Circuit
default_redirect_url = 'circuits:circuit_list'
filter = filters.CircuitFilter
default_return_url = 'circuits:circuit_list'
@permission_required('circuits.change_circuittermination')
@@ -208,7 +217,7 @@ def circuit_terminations_swap(request, pk):
'form': form,
'panel_class': 'default',
'button_class': 'primary',
'cancel_url': circuit.get_absolute_url(),
'return_url': circuit.get_absolute_url(),
})
@@ -220,15 +229,14 @@ 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, args, kwargs):
if 'circuit' in kwargs:
obj.circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'circuit' in url_kwargs:
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,196 +0,0 @@
from django.contrib import admin
from django.db.models import Count
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform,
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site,
)
@admin.register(Site)
class SiteAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'facility', 'asn']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(RackGroup)
class RackGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'site']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(RackRole)
class RackRoleAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'color']
prepopulated_fields = {
'slug': ['name'],
}
@admin.register(Rack)
class RackAdmin(admin.ModelAdmin):
list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height']
#
# Device types
#
@admin.register(Manufacturer)
class ManufacturerAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug']
class ConsolePortTemplateAdmin(admin.TabularInline):
model = ConsolePortTemplate
class ConsoleServerPortTemplateAdmin(admin.TabularInline):
model = ConsoleServerPortTemplate
class PowerPortTemplateAdmin(admin.TabularInline):
model = PowerPortTemplate
class PowerOutletTemplateAdmin(admin.TabularInline):
model = PowerOutletTemplate
class InterfaceTemplateAdmin(admin.TabularInline):
model = InterfaceTemplate
class DeviceBayTemplateAdmin(admin.TabularInline):
model = DeviceBayTemplate
@admin.register(DeviceType)
class DeviceTypeAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['model'],
}
inlines = [
ConsolePortTemplateAdmin,
ConsoleServerPortTemplateAdmin,
PowerPortTemplateAdmin,
PowerOutletTemplateAdmin,
InterfaceTemplateAdmin,
DeviceBayTemplateAdmin,
]
list_display = ['model', 'manufacturer', 'slug', 'part_number', 'u_height', 'console_ports', 'console_server_ports',
'power_ports', 'power_outlets', 'interfaces', 'device_bays']
list_filter = ['manufacturer']
def get_queryset(self, request):
return DeviceType.objects.annotate(
console_port_count=Count('console_port_templates', distinct=True),
cs_port_count=Count('cs_port_templates', distinct=True),
power_port_count=Count('power_port_templates', distinct=True),
power_outlet_count=Count('power_outlet_templates', distinct=True),
interface_count=Count('interface_templates', distinct=True),
devicebay_count=Count('device_bay_templates', distinct=True),
)
def console_ports(self, instance):
return instance.console_port_count
def console_server_ports(self, instance):
return instance.cs_port_count
def power_ports(self, instance):
return instance.power_port_count
def power_outlets(self, instance):
return instance.power_outlet_count
def interfaces(self, instance):
return instance.interface_count
def device_bays(self, instance):
return instance.devicebay_count
#
# Devices
#
@admin.register(DeviceRole)
class DeviceRoleAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'slug', 'color']
@admin.register(Platform)
class PlatformAdmin(admin.ModelAdmin):
prepopulated_fields = {
'slug': ['name'],
}
list_display = ['name', 'rpc_client']
class ConsolePortAdmin(admin.TabularInline):
model = ConsolePort
readonly_fields = ['cs_port']
class ConsoleServerPortAdmin(admin.TabularInline):
model = ConsoleServerPort
class PowerPortAdmin(admin.TabularInline):
model = PowerPort
readonly_fields = ['power_outlet']
class PowerOutletAdmin(admin.TabularInline):
model = PowerOutlet
class InterfaceAdmin(admin.TabularInline):
model = Interface
class DeviceBayAdmin(admin.TabularInline):
model = DeviceBay
fk_name = 'device'
readonly_fields = ['installed_device']
class ModuleAdmin(admin.TabularInline):
model = Module
readonly_fields = ['parent', 'discovered']
@admin.register(Device)
class DeviceAdmin(admin.ModelAdmin):
inlines = [
ConsolePortAdmin,
ConsoleServerPortAdmin,
PowerPortAdmin,
PowerOutletAdmin,
InterfaceAdmin,
DeviceBayAdmin,
ModuleAdmin,
]
list_display = ['display_name', 'device_type_full_name', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
'serial']
list_filter = ['device_role']
def get_queryset(self, request):
qs = super(DeviceAdmin, self).get_queryset(request)
return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip4', 'primary_ip6', 'rack')
def device_type_full_name(self, obj):
return obj.device_type.full_name
device_type_full_name.short_description = 'Device type'

View File

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

View File

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

View File

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

View File

@@ -5,18 +5,32 @@ from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from .models import (
ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site,
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES,
)
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = NullableModelMultipleChoiceFilter(
name='region',
queryset=Region.objects.all(),
label='Region (ID)',
)
region = NullableModelMultipleChoiceFilter(
name='region',
queryset=Region.objects.all(),
to_field_name='slug',
label='Region (slug)',
)
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
@@ -33,9 +47,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Site
fields = ['q', 'name', 'facility', 'asn']
def search(self, queryset, value):
qs_filter = Q(name__icontains=value) | Q(facility__icontains=value) | Q(physical_address__icontains=value) | \
Q(shipping_address__icontains=value) | Q(comments__icontains=value)
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(facility__icontains=value) |
Q(physical_address__icontains=value) |
Q(shipping_address__icontains=value) |
Q(comments__icontains=value)
)
try:
qs_filter |= Q(asn=int(value.strip()))
except ValueError:
@@ -58,11 +79,13 @@ class RackGroupFilter(django_filters.FilterSet):
class Meta:
model = RackGroup
fields = ['name']
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -114,7 +137,9 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Rack
fields = ['u_height']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(facility_id__icontains=value) |
@@ -122,9 +147,59 @@ 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(),
label='Rack (ID)',
)
class Meta:
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):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
@@ -141,10 +216,13 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = DeviceType
fields = ['model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device',
'subdevice_role']
fields = [
'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role',
]
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(manufacturer__name__icontains=value) |
Q(model__icontains=value) |
@@ -153,22 +231,79 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
class DeviceTypeComponentFilterSet(django_filters.FilterSet):
devicetype_id = django_filters.ModelMultipleChoiceFilter(
name='device_type',
queryset=DeviceType.objects.all(),
label='Device type (ID)',
)
devicetype = django_filters.ModelMultipleChoiceFilter(
name='device_type',
queryset=DeviceType.objects.all(),
to_field_name='name',
label='Device type (name)',
)
class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = ConsolePortTemplate
fields = ['name']
class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = ConsoleServerPortTemplate
fields = ['name']
class PowerPortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = PowerPortTemplate
fields = ['name']
class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = PowerOutletTemplate
fields = ['name']
class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = InterfaceTemplate
fields = ['name', 'form_factor']
class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = DeviceBayTemplate
fields = ['name']
class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
mac_address = django_filters.MethodFilter(
action='_mac_address',
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='rack__site__slug',
name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
@@ -178,7 +313,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=RackGroup.objects.all(),
label='Rack group (ID)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
rack_id = NullableModelMultipleChoiceFilter(
name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
@@ -238,10 +373,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Platform (slug)',
)
status = django_filters.BooleanFilter(
name='status',
label='Status',
)
is_console_server = django_filters.BooleanFilter(
name='device_type__is_console_server',
label='Is a console server',
@@ -254,21 +385,30 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
name='device_type__is_network_device',
label='Is a network device',
)
has_primary_ip = django_filters.BooleanFilter(
method='_has_primary_ip',
label='Has a primary IP',
)
status = django_filters.MultipleChoiceFilter(
choices=STATUS_CHOICES
)
class Meta:
model = Device
fields = ['name', 'serial', 'asset_tag']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(modules__serial__icontains=value.strip()) |
Q(inventory_items__serial__icontains=value.strip()) |
Q(asset_tag=value.strip()) |
Q(comments__icontains=value)
).distinct()
def _mac_address(self, queryset, value):
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
@@ -277,143 +417,191 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _has_primary_ip(self, queryset, name, value):
if value:
return queryset.filter(
Q(primary_ip4__isnull=False) |
Q(primary_ip6__isnull=False)
)
else:
return queryset.exclude(
Q(primary_ip4__isnull=False) |
Q(primary_ip6__isnull=False)
)
class ConsolePortFilter(django_filters.FilterSet):
class DeviceComponentFilterSet(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class ConsolePortFilter(DeviceComponentFilterSet):
class Meta:
model = ConsolePort
fields = ['name']
class ConsoleServerPortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class ConsoleServerPortFilter(DeviceComponentFilterSet):
class Meta:
model = ConsoleServerPort
fields = ['name']
class PowerPortFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class PowerPortFilter(DeviceComponentFilterSet):
class Meta:
model = PowerPort
fields = ['name']
class PowerOutletFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
class PowerOutletFilter(DeviceComponentFilterSet):
class Meta:
model = PowerOutlet
fields = ['name']
class InterfaceFilter(django_filters.FilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
label='Device (ID)',
class InterfaceFilter(DeviceComponentFilterSet):
type = django_filters.CharFilter(
method='filter_type',
label='Interface type',
)
device = django_filters.ModelMultipleChoiceFilter(
name='device',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
)
class Meta:
model = Interface
fields = ['name', 'form_factor']
def filter_type(self, queryset, name, value):
value = value.strip().lower()
if value == 'physical':
return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
elif value == 'virtual':
return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
elif value == 'lag':
return queryset.filter(form_factor=IFACE_FF_LAG)
return queryset
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
return queryset.filter(mac_address=value)
except AddrFormatError:
return queryset.none()
class DeviceBayFilter(DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ['name']
class InventoryItemFilter(DeviceComponentFilterSet):
class Meta:
model = InventoryItem
fields = ['name']
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
method='filter_device',
label='Device',
)
class Meta:
model = ConsoleServerPort
model = ConsolePort
fields = ['name', 'connection_status']
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(cs_port__device__rack__site__slug=value)
return queryset.filter(cs_port__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(cs_port__device__name__icontains=value)
)
class PowerConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
method='filter_device',
label='Device',
)
class Meta:
model = PowerOutlet
model = PowerPort
fields = ['name', 'connection_status']
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(power_outlet__device__rack__site__slug=value)
return queryset.filter(power_outlet__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(power_outlet__device__name__icontains=value)
)
class InterfaceConnectionFilter(django_filters.FilterSet):
site = django_filters.MethodFilter(
action='filter_site',
site = django_filters.CharFilter(
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
method='filter_device',
label='Device',
)
class Meta:
model = InterfaceConnection
fields = ['connection_status']
def filter_site(self, queryset, value):
value = value.strip()
if not value:
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(interface_a__device__rack__site__slug=value) |
Q(interface_b__device__rack__site__slug=value)
Q(interface_a__device__site__slug=value) |
Q(interface_b__device__site__slug=value)
)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(interface_a__device__name__icontains=value) |
Q(interface_b__device__name__icontains=value)
)

View File

@@ -1915,6 +1915,7 @@
"platform": 1,
"name": "test1-edge1",
"serial": "5555555555",
"site": 1,
"rack": 1,
"position": 1,
"face": 0,
@@ -1935,6 +1936,7 @@
"platform": 1,
"name": "test1-core1",
"serial": "",
"site": 1,
"rack": 1,
"position": 17,
"face": 0,
@@ -1955,6 +1957,7 @@
"platform": 1,
"name": "test1-spine1",
"serial": "",
"site": 1,
"rack": 1,
"position": 33,
"face": 0,
@@ -1975,6 +1978,7 @@
"platform": 1,
"name": "test1-leaf1",
"serial": "",
"site": 1,
"rack": 1,
"position": 34,
"face": 0,
@@ -1995,6 +1999,7 @@
"platform": 1,
"name": "test1-leaf2",
"serial": "9823478293748",
"site": 1,
"rack": 2,
"position": 34,
"face": 0,
@@ -2015,6 +2020,7 @@
"platform": 1,
"name": "test1-spine2",
"serial": "45649818158",
"site": 1,
"rack": 2,
"position": 33,
"face": 0,
@@ -2035,6 +2041,7 @@
"platform": 1,
"name": "test1-edge2",
"serial": "7567356345",
"site": 1,
"rack": 2,
"position": 1,
"face": 0,
@@ -2055,6 +2062,7 @@
"platform": 1,
"name": "test1-core2",
"serial": "67856734534",
"site": 1,
"rack": 2,
"position": 17,
"face": 0,
@@ -2075,6 +2083,7 @@
"platform": 2,
"name": "test1-oob1",
"serial": "98273942938",
"site": 1,
"rack": 1,
"position": 42,
"face": 0,
@@ -2095,6 +2104,7 @@
"platform": null,
"name": "test1-pdu1",
"serial": "",
"site": 1,
"rack": 1,
"position": null,
"face": null,
@@ -2115,6 +2125,7 @@
"platform": null,
"name": "test1-pdu2",
"serial": "",
"site": 1,
"rack": 2,
"position": null,
"face": null,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-16 18:43
from __future__ import unicode_literals
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('dcim', '0025_devicetype_add_interface_ordering'),
]
operations = [
migrations.CreateModel(
name='RackReservation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)),
('created', models.DateTimeField(auto_now_add=True)),
('description', models.CharField(max_length=100)),
('rack', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack')),
('user', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created'],
},
),
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-16 21:21
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0026_add_rack_reservations'),
]
operations = [
migrations.AddField(
model_name='device',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'),
),
]

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-16 21:23
from __future__ import unicode_literals
from django.db import migrations
def copy_site_from_rack(apps, schema_editor):
Device = apps.get_model('dcim', 'Device')
for device in Device.objects.all():
device.site = device.rack.site
device.save()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0027_device_add_site'),
]
operations = [
migrations.RunPython(copy_site_from_rack),
]

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-16 21:25
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0028_device_copy_rack_to_site'),
]
operations = [
migrations.AlterField(
model_name='device',
name='rack',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'),
),
migrations.AlterField(
model_name='device',
name='site',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'),
),
]

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-27 19:55
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0029_allow_rackless_devices'),
]
operations = [
migrations.AddField(
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=b'Parent LAG'),
),
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']]], [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']]], [b'Other', [[32767, b'Other']]]], default=1200),
),
]

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-28 17:14
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0030_interface_add_lag'),
]
operations = [
migrations.CreateModel(
name='Region',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True)),
('slug', models.SlugField(unique=True)),
('lft', models.PositiveIntegerField(db_index=True, editable=False)),
('rght', models.PositiveIntegerField(db_index=True, editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(db_index=True, editable=False)),
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.Region')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='site',
name='region',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.Region'),
),
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-03-02 15:09
from __future__ import unicode_literals
from django.db import migrations
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0031_regions'),
]
operations = [
migrations.AlterField(
model_name='device',
name='name',
field=utilities.fields.NullableCharField(blank=True, max_length=64, null=True, unique=True),
),
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.6 on 2017-03-17 18:39
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0032_device_increase_name_length'),
]
operations = [
migrations.AlterField(
model_name='rackreservation',
name='rack',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack'),
),
]

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.6 on 2017-03-21 14:55
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0033_rackreservation_rack_editable'),
]
operations = [
migrations.RenameModel(
old_name='Module',
new_name='InventoryItem',
),
migrations.AlterField(
model_name='inventoryitem',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='dcim.Device'),
),
migrations.AlterField(
model_name='inventoryitem',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.InventoryItem'),
),
migrations.AlterField(
model_name='inventoryitem',
name='manufacturer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.Manufacturer'),
),
]

View File

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

View File

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

View File

@@ -1,16 +1,22 @@
from collections import OrderedDict
from itertools import count, groupby
from mptt.models import MPTTModel, TreeForeignKey
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from circuits.models import Circuit
from extras.models import CustomFieldModel, CustomField, CustomFieldValue
from extras.models import CustomFieldModel, CustomField, CustomFieldValue, ImageAttachment
from extras.rpc import RPC_CLIENTS
from tenancy.models import Tenant
from utilities.fields import ColorField, NullableCharField
@@ -65,6 +71,7 @@ IFACE_ORDERING_CHOICES = [
# Virtual
IFACE_FF_VIRTUAL = 0
IFACE_FF_LAG = 200
# Ethernet
IFACE_FF_100ME_FIXED = 800
IFACE_FF_1GE_FIXED = 1000
@@ -95,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
@@ -103,6 +111,7 @@ IFACE_FF_CHOICES = [
'Virtual interfaces',
[
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
]
],
[
@@ -145,6 +154,7 @@ IFACE_FF_CHOICES = [
[IFACE_FF_E1, 'E1 (2.048 Mbps)'],
[IFACE_FF_T3, 'T3 (45 Mbps)'],
[IFACE_FF_E3, 'E3 (34 Mbps)'],
[IFACE_FF_E3, 'E3 (34 Mbps)'],
]
],
[
@@ -154,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'],
]
],
[
@@ -164,13 +175,35 @@ IFACE_FF_CHOICES = [
],
]
STATUS_ACTIVE = True
STATUS_OFFLINE = False
VIRTUAL_IFACE_TYPES = [
IFACE_FF_VIRTUAL,
IFACE_FF_LAG,
]
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 = [
@@ -189,6 +222,31 @@ RPC_CLIENT_CHOICES = [
]
#
# Regions
#
@python_2_unicode_compatible
class Region(MPTTModel):
"""
Sites can be grouped within geographic Regions.
"""
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)
class MPTTMeta:
order_insertion_by = ['name']
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
#
# Sites
#
@@ -199,6 +257,7 @@ class SiteManager(NaturalOrderByManager):
return self.natural_order_by('name')
@python_2_unicode_compatible
class Site(CreatedUpdatedModel, CustomFieldModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
@@ -206,7 +265,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='sites', on_delete=models.PROTECT)
region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
facility = models.CharField(max_length=50, blank=True)
asn = ASNField(blank=True, null=True, verbose_name='ASN')
physical_address = models.CharField(max_length=200, blank=True)
@@ -216,13 +276,14 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail")
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
objects = SiteManager()
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -232,6 +293,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
return csv_format([
self.name,
self.slug,
self.region.name if self.region else None,
self.tenant.name if self.tenant else None,
self.facility,
self.asn,
@@ -254,7 +316,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
@property
def count_devices(self):
return Device.objects.filter(rack__site=self).count()
return Device.objects.filter(site=self).count()
@property
def count_circuits(self):
@@ -265,6 +327,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
# Racks
#
@python_2_unicode_compatible
class RackGroup(models.Model):
"""
Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
@@ -273,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']
@@ -282,13 +345,14 @@ class RackGroup(models.Model):
['site', 'slug'],
]
def __unicode__(self):
def __str__(self):
return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
@python_2_unicode_compatible
class RackRole(models.Model):
"""
Racks can be organized by functional role, similar to Devices.
@@ -300,7 +364,7 @@ class RackRole(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -313,6 +377,7 @@ class RackManager(NaturalOrderByManager):
return self.natural_order_by('site__name', 'name')
@python_2_unicode_compatible
class Rack(CreatedUpdatedModel, CustomFieldModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -333,6 +398,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
help_text='Units are numbered top-to-bottom')
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
objects = RackManager()
@@ -343,8 +409,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
['site', 'facility_id'],
]
def __unicode__(self):
return self.display_name
def __str__(self):
return self.display_name or super(Rack, self).__str__()
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@@ -363,6 +429,19 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
)
})
def save(self, *args, **kwargs):
# Record the original site assignment for this rack.
_site_id = None
if self.pk:
_site_id = Rack.objects.get(pk=self.pk).site_id
super(Rack, self).save(*args, **kwargs)
# Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id:
Device.objects.filter(rack=self).update(site_id=self.site.pk)
def to_csv(self):
return csv_format([
self.site.name,
@@ -388,7 +467,9 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
def display_name(self):
if self.facility_id:
return u"{} ({})".format(self.name, self.facility_id)
return self.name
elif self.name:
return self.name
return u""
def get_rack_units(self, face=RACK_FACE_FRONT, exclude=None, remove_redundant=False):
"""
@@ -442,7 +523,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
devices = self.devices.select_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = range(1, self.u_height + 1)
units = list(range(1, self.u_height + 1))
# Remove units consumed by installed devices
for d in devices:
@@ -473,10 +554,64 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
return int(float(self.u_height - u_available) / self.u_height * 100)
@python_2_unicode_compatible
class RackReservation(models.Model):
"""
One or more reserved units within a Rack.
"""
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
units = ArrayField(models.PositiveSmallIntegerField())
created = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT)
description = models.CharField(max_length=100)
class Meta:
ordering = ['created']
def __str__(self):
return u"Reservation for rack {}".format(self.rack)
def clean(self):
if self.units:
# Validate that all specified units exist in the Rack.
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(
self.rack.u_height,
', '.join([str(u) for u in invalid_units]),
),
})
# Check that none of the units has already been reserved for this Rack.
reserved_units = []
for resv in self.rack.reservations.exclude(pk=self.pk):
reserved_units += resv.units
conflicting_units = [u for u in self.units if u in reserved_units]
if conflicting_units:
raise ValidationError({
'units': 'The following units have already been reserved: {}'.format(
', '.join([str(u) for u in conflicting_units]),
)
})
@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
#
@python_2_unicode_compatible
class Manufacturer(models.Model):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -487,13 +622,14 @@ class Manufacturer(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
@python_2_unicode_compatible
class DeviceType(models.Model, CustomFieldModel):
"""
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@@ -538,7 +674,7 @@ class DeviceType(models.Model, CustomFieldModel):
['manufacturer', 'slug'],
]
def __unicode__(self):
def __str__(self):
return self.model
def __init__(self, *args, **kwargs):
@@ -608,6 +744,7 @@ class DeviceType(models.Model, CustomFieldModel):
return bool(self.subdevice_role is False)
@python_2_unicode_compatible
class ConsolePortTemplate(models.Model):
"""
A template for a ConsolePort to be created for a new Device.
@@ -619,10 +756,11 @@ class ConsolePortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class ConsoleServerPortTemplate(models.Model):
"""
A template for a ConsoleServerPort to be created for a new Device.
@@ -634,10 +772,11 @@ class ConsoleServerPortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class PowerPortTemplate(models.Model):
"""
A template for a PowerPort to be created for a new Device.
@@ -649,10 +788,11 @@ class PowerPortTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class PowerOutletTemplate(models.Model):
"""
A template for a PowerOutlet to be created for a new Device.
@@ -664,7 +804,7 @@ class PowerOutletTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@@ -672,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:
@@ -688,24 +828,28 @@ 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)
@python_2_unicode_compatible
class InterfaceTemplate(models.Model):
"""
A template for a physical data interface on a new Device.
@@ -721,10 +865,11 @@ class InterfaceTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@python_2_unicode_compatible
class DeviceBayTemplate(models.Model):
"""
A template for a DeviceBay to be created for a new parent Device.
@@ -736,7 +881,7 @@ class DeviceBayTemplate(models.Model):
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@@ -744,6 +889,7 @@ class DeviceBayTemplate(models.Model):
# Devices
#
@python_2_unicode_compatible
class DeviceRole(models.Model):
"""
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@@ -756,13 +902,14 @@ class DeviceRole(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?role={}".format(reverse('dcim:device_list'), self.slug)
@python_2_unicode_compatible
class Platform(models.Model):
"""
Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
@@ -776,7 +923,7 @@ class Platform(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -789,38 +936,48 @@ class DeviceManager(NaturalOrderByManager):
return self.natural_order_by('name')
@python_2_unicode_compatible
class Device(CreatedUpdatedModel, CustomFieldModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
Each Device must be assigned to a Rack, although associating it with a particular rack face or unit is optional (for
example, vertically mounted PDUs do not consume rack units).
Each Device must be assigned to a site, and optionally to a rack within that site. Associating a device with a
particular rack face or unit is optional (for example, vertically mounted PDUs do not consume rack units).
When a new Device is created, console/power/interface components are created along with it as dictated by the
component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
When a new Device is created, console/power/interface/device bay components are created along with it as dictated
by the component templates assigned to its DeviceType. Components can also be added, modified, or deleted after the
creation of a Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT)
device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='devices', on_delete=models.PROTECT)
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
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')
rack = models.ForeignKey('Rack', related_name='devices', 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')
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'
)
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip4 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IPv4')
primary_ip6 = models.OneToOneField('ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL,
blank=True, null=True, verbose_name='Primary IPv6')
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
primary_ip4 = models.OneToOneField(
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
verbose_name='Primary IPv4'
)
primary_ip6 = models.OneToOneField(
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
verbose_name='Primary IPv6'
)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
objects = DeviceManager()
@@ -828,49 +985,68 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
ordering = ['name']
unique_together = ['rack', 'position', 'face']
def __unicode__(self):
return self.display_name
def __str__(self):
return self.display_name or super(Device, self).__str__()
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
def clean(self):
# Validate site/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site),
})
if self.rack is None:
if self.face is not None:
raise ValidationError({
'face': "Cannot select a rack face without assigning a rack.",
})
if self.position:
raise ValidationError({
'face': "Cannot select a rack position without assigning a rack.",
})
# Validate position/face combination
if self.position and self.face is None:
raise ValidationError({
'face': "Must specify rack face when defining rack position."
'face': "Must specify rack face when defining rack position.",
})
try:
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face is not None:
raise ValidationError({
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent "
"device."
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
'position': "Child device types cannot be assigned to a rack position. This is an attribute of the "
"parent device."
})
if self.rack:
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
exclude=exclude_list)
if self.position and self.position not in available_units:
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face is not None:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} "
"({}U).".format(self.position, self.device_type, self.device_type.u_height)
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
"parent device."
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
'position': "Child device types cannot be assigned to a rack position. This is an attribute of "
"the parent device."
})
except Rack.DoesNotExist:
pass
except DeviceType.DoesNotExist:
pass
# Validate rack space
rack_face = self.face if not self.device_type.is_full_depth else None
exclude_list = [self.pk] if self.pk else []
try:
available_units = self.rack.get_available_units(
u_height=self.device_type.u_height, rack_face=rack_face, exclude=exclude_list
)
if self.position and self.position not in available_units:
raise ValidationError({
'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) "
"{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)
})
except Rack.DoesNotExist:
pass
except DeviceType.DoesNotExist:
pass
def save(self, *args, **kwargs):
@@ -905,8 +1081,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
self.device_type.device_bay_templates.all()]
)
# Update Rack assignment for any child Devices
Device.objects.filter(parent_bay__device=self).update(rack=self.rack)
# Update Site and Rack assignment for any child Devices
Device.objects.filter(parent_bay__device=self).update(site=self.site, rack=self.rack)
def to_csv(self):
return csv_format([
@@ -918,8 +1094,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
self.platform.name if self.platform else None,
self.serial,
self.asset_tag,
self.rack.site.name,
self.rack.name,
self.get_status_display(),
self.site.name,
self.rack.name if self.rack else None,
self.position,
self.get_face_display(),
])
@@ -928,10 +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)
else:
return u"{} ({})".format(self.device_type, self.rack.name)
elif hasattr(self, 'device_type'):
return u"{}".format(self.device_type)
return u""
@property
def identifier(self):
@@ -959,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.
@@ -968,6 +1147,11 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
return RPC_CLIENTS.get(self.platform.rpc_client)
#
# Console ports
#
@python_2_unicode_compatible
class ConsolePort(models.Model):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -982,7 +1166,7 @@ class ConsolePort(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
# Used for connections export
@@ -996,6 +1180,10 @@ class ConsolePort(models.Model):
])
#
# Console server ports
#
class ConsoleServerPortManager(models.Manager):
def get_queryset(self):
@@ -1011,6 +1199,7 @@ class ConsoleServerPortManager(models.Manager):
}).order_by('device', 'name_as_integer')
@python_2_unicode_compatible
class ConsoleServerPort(models.Model):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@@ -1023,10 +1212,15 @@ class ConsoleServerPort(models.Model):
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
#
# Power ports
#
@python_2_unicode_compatible
class PowerPort(models.Model):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -1041,12 +1235,12 @@ class PowerPort(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
# Used for connections export
def csv_format(self):
return ','.join([
def to_csv(self):
return csv_format([
self.power_outlet.device.identifier if self.power_outlet else None,
self.power_outlet.name if self.power_outlet else None,
self.device.identifier,
@@ -1055,6 +1249,10 @@ class PowerPort(models.Model):
])
#
# Power outlets
#
class PowerOutletManager(models.Manager):
def get_queryset(self):
@@ -1064,6 +1262,7 @@ class PowerOutletManager(models.Manager):
}).order_by('device', 'name_padded')
@python_2_unicode_compatible
class PowerOutlet(models.Model):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -1076,16 +1275,23 @@ class PowerOutlet(models.Model):
class Meta:
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
#
# Interfaces
#
@python_2_unicode_compatible
class Interface(models.Model):
"""
A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
of an InterfaceConnection.
"""
device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='Parent LAG')
name = models.CharField(max_length=30)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
@@ -1099,20 +1305,47 @@ class Interface(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
# Virtual interfaces cannot be connected
if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected:
raise ValidationError({
'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
"interface or choose a physical form factor."
})
# 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(
self.lag.name, self.lag.device.name
)
})
# A virtual interface cannot have a parent LAG
if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None:
raise ValidationError({
'lag': u"{} 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()])
)
})
@property
def is_physical(self):
return self.form_factor != IFACE_FF_VIRTUAL
def is_virtual(self):
return self.form_factor in VIRTUAL_IFACE_TYPES
@property
def is_lag(self):
return self.form_factor == IFACE_FF_LAG
@property
def is_connected(self):
@@ -1176,6 +1409,11 @@ class InterfaceConnection(models.Model):
])
#
# Device bays
#
@python_2_unicode_compatible
class DeviceBay(models.Model):
"""
An empty space within a Device which can house a child device
@@ -1189,7 +1427,7 @@ class DeviceBay(models.Model):
ordering = ['device', 'name']
unique_together = ['device', 'name']
def __unicode__(self):
def __str__(self):
return u'{} - {}'.format(self.device.name, self.name)
def clean(self):
@@ -1205,15 +1443,20 @@ class DeviceBay(models.Model):
raise ValidationError("Cannot install a device into itself.")
class Module(models.Model):
#
# Inventory items
#
@python_2_unicode_compatible
class InventoryItem(models.Model):
"""
A Module represents a piece of hardware within a Device, such as a line card or power supply. Modules are used only
for inventory purposes.
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes.
"""
device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE)
parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE)
device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE)
parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE)
name = models.CharField(max_length=50, verbose_name='Name')
manufacturer = models.ForeignKey('Manufacturer', related_name='modules', blank=True, null=True,
manufacturer = models.ForeignKey('Manufacturer', related_name='inventory_items', blank=True, null=True,
on_delete=models.PROTECT)
part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True)
serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True)
@@ -1223,5 +1466,5 @@ class Module(models.Model):
ordering = ['device__id', 'parent__id', 'name']
unique_together = ['device', 'parent', 'name']
def __unicode__(self):
def __str__(self):
return self.name

View File

@@ -1,15 +1,33 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, ToggleColumn
from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, Site,
RackGroup, RackReservation, Region, Site,
)
REGION_LINK = """
{% if record.get_children %}
<span style="padding-left: {{ record.get_ancestors|length }}0px "><i class="fa fa-caret-right"></i>
{% else %}
<span style="padding-left: {{ record.get_ancestors|length }}9px">
{% endif %}
<a href="{% url 'dcim:site_list' %}?region={{ record.slug }}">{{ record.name }}</a>
</span>
"""
SITE_REGION_LINK = """
{% if record.region %}
<a href="{% url 'dcim:site_list' %}?region={{ record.region.slug }}">{{ record.region }}</a>
{% else %}
&mdash;
{% endif %}
"""
COLOR_LABEL = """
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
"""
@@ -20,6 +38,12 @@ DEVICE_LINK = """
</a>
"""
REGION_ACTIONS = """
{% if perms.dcim.change_region %}
<a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
RACKGROUP_ACTIONS = """
{% if perms.dcim.change_rackgroup %}
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -40,6 +64,12 @@ RACK_ROLE = """
{% endif %}
"""
RACKRESERVATION_ACTIONS = """
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
DEVICEROLE_ACTIONS = """
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -62,12 +92,18 @@ 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 = """
{{ record.primary_ip6.address.ip|default:"" }}
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
{{ record.primary_ip4.address.ip|default:"" }}
"""
SUBDEVICE_ROLE_TEMPLATE = """
{% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}&mdash;{% endif %}
"""
UTILIZATION_GRAPH = """
@@ -76,16 +112,35 @@ UTILIZATION_GRAPH = """
"""
#
# Regions
#
class RegionTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(template_code=REGION_LINK, orderable=False)
site_count = tables.Column(verbose_name='Sites')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(
template_code=REGION_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'name', 'site_count', 'slug', 'actions')
#
# Sites
#
class SiteTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name')
facility = tables.Column(verbose_name='Facility')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
asn = tables.Column(verbose_name='ASN')
name = tables.LinkColumn()
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
@@ -94,8 +149,20 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta):
model = Site
fields = ('pk', 'name', 'facility', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
'vlan_count', 'circuit_count')
fields = (
'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
'vlan_count', 'circuit_count',
)
class SiteSearchTable(SearchTable):
name = tables.LinkColumn()
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
class Meta(SearchTable.Meta):
model = Site
fields = ('name', 'facility', 'region', 'tenant', 'asn')
#
@@ -140,20 +207,33 @@ class RackRoleTable(BaseTable):
class RackTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
name = tables.LinkColumn()
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
facility_id = tables.Column(verbose_name='Facility ID')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
role = tables.TemplateColumn(RACK_ROLE, verbose_name='Role')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
role = tables.TemplateColumn(RACK_ROLE)
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices')
devices = tables.Column(accessor=Accessor('device_count'))
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
class Meta(BaseTable.Meta):
model = Rack
fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices',
'get_utilization')
fields = (
'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization'
)
class RackSearchTable(SearchTable):
name = tables.LinkColumn()
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
role = tables.TemplateColumn(RACK_ROLE)
u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height')
class Meta(SearchTable.Meta):
model = Rack
fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height')
class RackImportTable(BaseTable):
@@ -169,6 +249,23 @@ class RackImportTable(BaseTable):
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
#
# Rack reservations
#
class RackReservationTable(BaseTable):
pk = ToggleColumn()
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
unit_list = tables.Column(orderable=False, verbose_name='Units')
actions = tables.TemplateColumn(
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
)
class Meta(BaseTable.Meta):
model = RackReservation
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
#
# Manufacturers
#
@@ -192,15 +289,36 @@ class ManufacturerTable(BaseTable):
class DeviceTypeTable(BaseTable):
pk = ToggleColumn()
manufacturer = tables.Column(verbose_name='Manufacturer')
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
part_number = tables.Column(verbose_name='Part Number')
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
is_console_server = tables.BooleanColumn(verbose_name='CS')
is_pdu = tables.BooleanColumn(verbose_name='PDU')
is_network_device = tables.BooleanColumn(verbose_name='Net')
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
instance_count = tables.Column(verbose_name='Instances')
class Meta(BaseTable.Meta):
model = DeviceType
fields = ('pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count')
fields = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
'is_network_device', 'subdevice_role', 'instance_count'
)
class DeviceTypeSearchTable(SearchTable):
model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
is_console_server = tables.BooleanColumn(verbose_name='CS')
is_pdu = tables.BooleanColumn(verbose_name='PDU')
is_network_device = tables.BooleanColumn(verbose_name='Net')
subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
class Meta(SearchTable.Meta):
model = DeviceType
fields = (
'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu',
'is_network_device', 'subdevice_role',
)
#
@@ -294,12 +412,13 @@ class PlatformTable(BaseTable):
name = tables.LinkColumn(verbose_name='Name')
device_count = tables.Column(verbose_name='Devices')
slug = tables.Column(verbose_name='Slug')
rpc_client = tables.Column(accessor='get_rpc_client_display', orderable=False, verbose_name='RPC Client')
actions = tables.TemplateColumn(template_code=PLATFORM_ACTIONS, attrs={'td': {'class': 'text-right'}},
verbose_name='')
class Meta(BaseTable.Meta):
model = Platform
fields = ('pk', 'name', 'device_count', 'slug', 'actions')
fields = ('pk', 'name', 'device_count', 'slug', 'rpc_client', 'actions')
#
@@ -308,28 +427,47 @@ class PlatformTable(BaseTable):
class DeviceTable(BaseTable):
pk = ToggleColumn()
status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='')
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')],
verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
name = tables.TemplateColumn(template_code=DEVICE_LINK)
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
text=lambda record: record.device_type.full_name)
primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address',
template_code="{{ record.primary_ip.address.ip }}")
device_type = tables.LinkColumn(
'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
text=lambda record: record.device_type.full_name
)
primary_ip = tables.TemplateColumn(
orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP
)
class Meta(BaseTable.Meta):
model = Device
fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip')
class DeviceSearchTable(SearchTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK)
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.LinkColumn(
'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
text=lambda record: record.device_type.full_name
)
class Meta(SearchTable.Meta):
model = Device
fields = ('name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type')
class DeviceImportTable(BaseTable):
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
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', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')],
verbose_name='Site')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
position = tables.Column(verbose_name='Position')
device_role = tables.Column(verbose_name='Role')
@@ -337,7 +475,7 @@ class DeviceImportTable(BaseTable):
class Meta(BaseTable.Meta):
model = Device
fields = ('name', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
fields = ('name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False

File diff suppressed because it is too large Load Diff

View File

@@ -1,667 +0,0 @@
import json
from rest_framework import status
from rest_framework.test import APITestCase
from django.conf import settings
class SiteTest(APITestCase):
fixtures = [
'dcim',
'ipam',
'extras',
]
standard_fields = [
'id',
'name',
'slug',
'tenant',
'facility',
'asn',
'physical_address',
'shipping_address',
'contact_name',
'contact_phone',
'contact_email',
'comments',
'custom_fields',
'count_prefixes',
'count_vlans',
'count_racks',
'count_devices',
'count_circuits'
]
nested_fields = [
'id',
'name',
'slug'
]
rack_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'tenant',
'role',
'type',
'width',
'u_height',
'desc_units',
'comments',
'custom_fields',
]
graph_fields = [
'name',
'embed_url',
'embed_link',
]
def test_get_list(self, endpoint='/{}api/dcim/sites/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/sites/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
def test_get_site_list_rack(self, endpoint='/{}api/dcim/sites/1/racks/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
self.assertEqual(
sorted(i.keys()),
sorted(self.rack_fields),
)
# Check Nested Serializer.
self.assertEqual(
sorted(i.get('site').keys()),
sorted(self.nested_fields),
)
def test_get_site_list_graphs(self, endpoint='/{}api/dcim/sites/1/graphs/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in json.loads(response.content):
self.assertEqual(
sorted(i.keys()),
sorted(self.graph_fields),
)
class RackTest(APITestCase):
fixtures = [
'dcim',
'ipam'
]
nested_fields = [
'id',
'name',
'facility_id',
'display_name'
]
standard_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'tenant',
'role',
'type',
'width',
'u_height',
'desc_units',
'comments',
'custom_fields',
]
detail_fields = [
'id',
'name',
'facility_id',
'display_name',
'site',
'group',
'tenant',
'role',
'type',
'width',
'u_height',
'desc_units',
'comments',
'custom_fields',
'front_units',
'rear_units'
]
def test_get_list(self, endpoint='/{}api/dcim/racks/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(i.get('site').keys()),
sorted(SiteTest.nested_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/racks/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.detail_fields),
)
self.assertEqual(
sorted(content.get('site').keys()),
sorted(SiteTest.nested_fields),
)
class ManufacturersTest(APITestCase):
fixtures = [
'dcim',
'ipam'
]
standard_fields = [
'id',
'name',
'slug',
]
nested_fields = standard_fields
def test_get_list(self, endpoint='/{}api/dcim/manufacturers/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/manufacturers/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class DeviceTypeTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'id',
'manufacturer',
'model',
'slug',
'part_number',
'u_height',
'is_full_depth',
'interface_ordering',
'is_console_server',
'is_pdu',
'is_network_device',
'subdevice_role',
'comments',
'custom_fields',
]
nested_fields = [
'id',
'manufacturer',
'model',
'slug'
]
def test_get_list(self, endpoint='/{}api/dcim/device-types/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_detail_list(self, endpoint='/{}api/dcim/device-types/1/'.format(settings.BASE_PATH)):
# TODO: details returns list view.
# response = self.client.get(endpoint)
# content = json.loads(response.content)
# self.assertEqual(response.status_code, status.HTTP_200_OK)
# self.assertEqual(
# sorted(content.keys()),
# sorted(self.standard_fields),
# )
# self.assertEqual(
# sorted(content.get('manufacturer').keys()),
# sorted(ManufacturersTest.nested_fields),
# )
pass
class DeviceRolesTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'name', 'slug', 'color']
nested_fields = ['id', 'name', 'slug']
def test_get_list(self, endpoint='/{}api/dcim/device-roles/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/device-roles/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class PlatformsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'name', 'slug', 'rpc_client']
nested_fields = ['id', 'name', 'slug']
def test_get_list(self, endpoint='/{}api/dcim/platforms/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/platforms/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class DeviceTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'id',
'name',
'display_name',
'device_type',
'device_role',
'tenant',
'platform',
'serial',
'asset_tag',
'rack',
'position',
'face',
'parent_device',
'status',
'primary_ip',
'primary_ip4',
'primary_ip6',
'comments',
'custom_fields',
]
nested_fields = ['id', 'name', 'display_name']
def test_get_list(self, endpoint='/{}api/dcim/devices/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for device in content:
self.assertEqual(
sorted(device.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(device.get('device_type')),
sorted(DeviceTypeTest.nested_fields),
)
self.assertEqual(
sorted(device.get('device_role')),
sorted(DeviceRolesTest.nested_fields),
)
if device.get('platform'):
self.assertEqual(
sorted(device.get('platform')),
sorted(PlatformsTest.nested_fields),
)
self.assertEqual(
sorted(device.get('rack')),
sorted(RackTest.nested_fields),
)
def test_get_list_flat(self, endpoint='/{}api/dcim/devices/?format=json_flat'.format(settings.BASE_PATH)):
flat_fields = [
'asset_tag',
'comments',
'device_role_id',
'device_role_name',
'device_role_slug',
'device_type_id',
'device_type_manufacturer_id',
'device_type_manufacturer_name',
'device_type_manufacturer_slug',
'device_type_model',
'device_type_slug',
'display_name',
'face',
'id',
'name',
'parent_device',
'platform_id',
'platform_name',
'platform_slug',
'position',
'primary_ip_address',
'primary_ip_family',
'primary_ip_id',
'primary_ip4_address',
'primary_ip4_family',
'primary_ip4_id',
'primary_ip6',
'rack_display_name',
'rack_facility_id',
'rack_id',
'rack_name',
'serial',
'status',
'tenant',
]
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
device = content[0]
self.assertEqual(
sorted(device.keys()),
sorted(flat_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/devices/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
class ConsoleServerPortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'connected_console']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/{}api/dcim/devices/9/console-server-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
sorted(console_port.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(console_port.get('device')),
sorted(DeviceTest.nested_fields),
)
class ConsolePortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'cs_port', 'connection_status']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/{}api/dcim/devices/1/console-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for console_port in content:
self.assertEqual(
sorted(console_port.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(console_port.get('device')),
sorted(DeviceTest.nested_fields),
)
self.assertEqual(
sorted(console_port.get('cs_port')),
sorted(ConsoleServerPortsTest.nested_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/console-ports/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
class PowerPortsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'power_outlet', 'connection_status']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/{}api/dcim/devices/1/power-ports/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/power-ports/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
class PowerOutletsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = ['id', 'device', 'name', 'connected_port']
nested_fields = ['id', 'device', 'name']
def test_get_list(self, endpoint='/{}api/dcim/devices/11/power-outlets/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
class InterfaceTest(APITestCase):
fixtures = ['dcim', 'ipam', 'extras']
standard_fields = [
'id',
'device',
'name',
'form_factor',
'mac_address',
'mgmt_only',
'description',
'is_connected'
]
nested_fields = ['id', 'device', 'name']
detail_fields = [
'id',
'device',
'name',
'form_factor',
'mac_address',
'mgmt_only',
'description',
'is_connected',
'connected_interface'
]
connection_fields = [
'id',
'interface_a',
'interface_b',
'connection_status',
]
def test_get_list(self, endpoint='/{}api/dcim/devices/1/interfaces/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(self.standard_fields),
)
self.assertEqual(
sorted(i.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_detail(self, endpoint='/{}api/dcim/interfaces/1/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.detail_fields),
)
self.assertEqual(
sorted(content.get('device')),
sorted(DeviceTest.nested_fields),
)
def test_get_graph_list(self, endpoint='/{}api/dcim/interfaces/1/graphs/'.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for i in content:
self.assertEqual(
sorted(i.keys()),
sorted(SiteTest.graph_fields),
)
def test_get_interface_connections(self, endpoint='/{}api/dcim/interface-connections/4/'
.format(settings.BASE_PATH)):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.connection_fields),
)
class RelatedConnectionsTest(APITestCase):
fixtures = ['dcim', 'ipam']
standard_fields = [
'device',
'console-ports',
'power-ports',
'interfaces',
]
def test_get_list(self, endpoint=('/{}api/dcim/related-connections/?peer-device=test1-edge1&peer-interface=xe-0/0/3'
.format(settings.BASE_PATH))):
response = self.client.get(endpoint)
content = json.loads(response.content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
sorted(content.keys()),
sorted(self.standard_fields),
)

View File

@@ -6,14 +6,14 @@ class RackTestCase(TestCase):
def setUp(self):
site = Site.objects.create(
self.site = Site.objects.create(
name='TestSite1',
slug='my-test-site'
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101',
site=site,
site=self.site,
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
@@ -56,29 +56,29 @@ class RackTestCase(TestCase):
def test_mount_single_device(self):
rack1 = Rack.objects.get(name='TestRack1')
device1 = Device(
name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
rack=rack1,
site=self.site,
rack=self.rack,
position=10,
face=RACK_FACE_REAR,
)
device1.save()
# Validate rack height
self.assertEqual(list(rack1.units), list(reversed(range(1, 43))))
self.assertEqual(list(self.rack.units), list(reversed(range(1, 43))))
# Validate inventory (front face)
rack1_inventory_front = rack1.get_front_elevation()
rack1_inventory_front = self.rack.get_front_elevation()
self.assertEqual(rack1_inventory_front[-10]['device'], device1)
del(rack1_inventory_front[-10])
for u in rack1_inventory_front:
self.assertIsNone(u['device'])
# Validate inventory (rear face)
rack1_inventory_rear = rack1.get_rear_elevation()
rack1_inventory_rear = self.rack.get_rear_elevation()
self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
del(rack1_inventory_rear[-10])
for u in rack1_inventory_rear:
@@ -89,6 +89,7 @@ class RackTestCase(TestCase):
name='TestPDU',
device_role=self.role.get('PDU'),
device_type=self.device_type.get('cc5000'),
site=self.site,
rack=self.rack,
position=None,
face=None,

View File

@@ -3,11 +3,20 @@ from django.conf.urls import url
from ipam.views import ServiceEditView
from secrets.views import secret_add
from extras.views import ImageAttachmentEditView
from .models import Device, Rack, Site
from . import views
app_name = 'dcim'
urlpatterns = [
# Regions
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
url(r'^regions/add/$', views.RegionEditView.as_view(), name='region_add'),
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
# Sites
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
url(r'^sites/add/$', views.SiteEditView.as_view(), name='site_add'),
@@ -16,6 +25,7 @@ urlpatterns = [
url(r'^sites/(?P<slug>[\w-]+)/$', views.site, name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
@@ -29,8 +39,15 @@ urlpatterns = [
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
# 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'),
@@ -38,6 +55,8 @@ urlpatterns = [
url(r'^racks/(?P<pk>\d+)/$', views.rack, 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'),
url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@@ -103,9 +122,9 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.device_inventory, name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'),
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
@@ -162,6 +181,11 @@ urlpatterns = [
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
# Inventory items
url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
# Console/power/interface connections
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
@@ -170,9 +194,4 @@ urlpatterns = [
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
# Modules
url(r'^devices/(?P<device>\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'),
url(r'^modules/(?P<pk>\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'),
url(r'^modules/(?P<pk>\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'),
]

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,88 +0,0 @@
import json
from rest_framework import renderers
# IP address family designations
AF = {
4: 'A',
6: 'AAAA',
}
class FormlessBrowsableAPIRenderer(renderers.BrowsableAPIRenderer):
"""
An instance of the browseable API with forms suppressed. Useful for POST endpoints that don't create objects.
"""
def show_form_for_method(self, *args, **kwargs):
return False
class BINDZoneRenderer(renderers.BaseRenderer):
"""
Generate a BIND zone file from a list of DNS records.
Required fields: `name`, `primary_ip`
"""
media_type = 'text/plain'
format = 'bind-zone'
def render(self, data, media_type=None, renderer_context=None):
records = []
for record in data:
if record.get('name') and record.get('primary_ip'):
try:
records.append("{} IN {} {}".format(
record['name'],
AF[record['primary_ip']['family']],
record['primary_ip']['address'].split('/')[0],
))
except KeyError:
pass
return '\n'.join(records)
class FlatJSONRenderer(renderers.BaseRenderer):
"""
Flattens a nested JSON response.
"""
format = 'json_flat'
media_type = 'application/json'
def render(self, data, media_type=None, renderer_context=None):
def flatten(entry):
for key, val in entry.iteritems():
if isinstance(val, dict):
for child_key, child_val in flatten(val):
yield "{}_{}".format(key, child_key), child_val
else:
yield key, val
return json.dumps([dict(flatten(i)) for i in data])
class FreeRADIUSClientsRenderer(renderers.BaseRenderer):
"""
Generate a FreeRADIUS clients.conf file from a list of Secrets.
"""
media_type = 'text/plain'
format = 'freeradius'
CLIENT_TEMPLATE = """client {name} {{
ipaddr = {ip}
secret = {secret}
}}"""
def render(self, data, media_type=None, renderer_context=None):
clients = []
try:
for secret in data:
if secret['device']['primary_ip'] and secret['plaintext']:
client = self.CLIENT_TEMPLATE.format(
name=secret['device']['name'],
ip=secret['device']['primary_ip']['address'].split('/')[0],
secret=secret['plaintext']
)
clients.append(client)
except:
pass
return '\n'.join(clients)

View File

@@ -1,56 +1,135 @@
from rest_framework import serializers
from extras.models import CF_TYPE_SELECT, CustomFieldChoice, Graph
from django.core.exceptions import ObjectDoesNotExist
from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
from dcim.models import Device, Rack, Site
from extras.models import (
ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction,
)
from users.api.serializers import NestedUserSerializer
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer
class CustomFieldSerializer(serializers.Serializer):
"""
Extends a ModelSerializer to render any CustomFields and their values associated with an object.
"""
custom_fields = serializers.SerializerMethodField()
def get_custom_fields(self, obj):
# Gather all CustomFields applicable to this object
fields = {cf.name: None for cf in self.context['view'].custom_fields}
# Attach any defined CustomFieldValues to their respective CustomFields
for cfv in obj.custom_field_values.all():
# Attempt to suppress database lookups for CustomFieldChoices by using the cached choice set from the view
# context.
if cfv.field.type == CF_TYPE_SELECT and hasattr(self, 'custom_field_choices'):
cfc = {
'id': int(cfv.serialized_value),
'value': self.context['view'].custom_field_choices[int(cfv.serialized_value)]
}
fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfc).data
# Fall back to hitting the database in case we're in a view that doesn't inherit CustomFieldModelAPIView.
elif cfv.field.type == CF_TYPE_SELECT:
fields[cfv.field.name] = CustomFieldChoiceSerializer(instance=cfv.value).data
else:
fields[cfv.field.name] = cfv.value
return fields
class CustomFieldChoiceSerializer(serializers.ModelSerializer):
class Meta:
model = CustomFieldChoice
fields = ['id', 'value']
#
# Graphs
#
class GraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField()
embed_link = serializers.SerializerMethodField()
type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
class Meta:
model = Graph
fields = ['name', 'embed_url', 'embed_link']
fields = ['id', 'type', 'weight', 'name', 'source', 'link']
class WritableGraphSerializer(serializers.ModelSerializer):
class Meta:
model = Graph
fields = ['id', 'type', 'weight', 'name', 'source', 'link']
class RenderedGraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField()
embed_link = serializers.SerializerMethodField()
type = ChoiceFieldSerializer(choices=GRAPH_TYPE_CHOICES)
class Meta:
model = Graph
fields = ['id', 'type', 'weight', 'name', 'embed_url', 'embed_link']
def get_embed_url(self, obj):
return obj.embed_url(self.context['graphed_object'])
def get_embed_link(self, obj):
return obj.embed_link(self.context['graphed_object'])
#
# Export templates
#
class ExportTemplateSerializer(serializers.ModelSerializer):
class Meta:
model = ExportTemplate
fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
#
# Topology maps
#
class TopologyMapSerializer(serializers.ModelSerializer):
site = NestedSiteSerializer()
class Meta:
model = TopologyMap
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
class WritableTopologyMapSerializer(serializers.ModelSerializer):
class Meta:
model = TopologyMap
fields = ['id', 'name', 'slug', 'site', 'device_patterns', 'description']
#
# Image attachments
#
class ImageAttachmentSerializer(serializers.ModelSerializer):
parent = serializers.SerializerMethodField()
class Meta:
model = ImageAttachment
fields = ['id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created']
def get_parent(self, obj):
# Static mapping of models to their nested serializers
if isinstance(obj.parent, Device):
serializer = NestedDeviceSerializer
elif isinstance(obj.parent, Rack):
serializer = NestedRackSerializer
elif isinstance(obj.parent, Site):
serializer = NestedSiteSerializer
else:
raise Exception("Unexpected type of parent object for ImageAttachment")
return serializer(obj.parent, context={'request': self.context['request']}).data
class WritableImageAttachmentSerializer(serializers.ModelSerializer):
content_type = ContentTypeFieldSerializer()
class Meta:
model = ImageAttachment
fields = ['id', 'content_type', 'object_id', 'name', 'image']
def validate(self, data):
# Validate that the parent object exists
try:
data['content_type'].get_object_for_this_type(id=data['object_id'])
except ObjectDoesNotExist:
raise serializers.ValidationError(
"Invalid parent object: {} ID {}".format(data['content_type'], data['object_id'])
)
return data
#
# User actions
#
class UserActionSerializer(serializers.ModelSerializer):
user = NestedUserSerializer()
action = ChoiceFieldSerializer(choices=ACTION_CHOICES)
class Meta:
model = UserAction
fields = ['id', 'time', 'user', 'action', 'message']

33
netbox/extras/api/urls.py Normal file
View File

@@ -0,0 +1,33 @@
from rest_framework import routers
from . import views
class ExtrasRootView(routers.APIRootView):
"""
Extras API root view
"""
def get_view_name(self):
return 'Extras'
router = routers.DefaultRouter()
router.APIRootView = ExtrasRootView
# Graphs
router.register(r'graphs', views.GraphViewSet)
# Export templates
router.register(r'export-templates', views.ExportTemplateViewSet)
# Topology maps
router.register(r'topology-maps', views.TopologyMapViewSet)
# Image attachments
router.register(r'image-attachments', views.ImageAttachmentViewSet)
# Recent activity
router.register(r'recent-activity', views.RecentActivityViewSet)
app_name = 'extras-api'
urlpatterns = router.urls

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-04-04 19:45
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0004_topologymap_change_comma_to_semicolon'),
]
operations = [
migrations.AlterField(
model_name='useraction',
name='action',
field=models.PositiveSmallIntegerField(choices=[(1, b'created'), (7, b'bulk created'), (2, b'imported'), (3, b'modified'), (4, b'bulk edited'), (5, b'deleted'), (6, b'bulk deleted')]),
),
]

View File

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

View File

@@ -1,15 +1,20 @@
from collections import OrderedDict
from datetime import date
import graphviz
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import ValidationError
from django.db import models
from django.db.models import Q
from django.http import HttpResponse
from django.template import Template, Context
from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
from utilities.utils import foreground_color
CUSTOMFIELD_MODELS = (
'site', 'rack', 'devicetype', 'device', # DCIM
@@ -55,16 +60,22 @@ ACTION_EDIT = 3
ACTION_BULK_EDIT = 4
ACTION_DELETE = 5
ACTION_BULK_DELETE = 6
ACTION_BULK_CREATE = 7
ACTION_CHOICES = (
(ACTION_CREATE, 'created'),
(ACTION_BULK_CREATE, 'bulk created'),
(ACTION_IMPORT, 'imported'),
(ACTION_EDIT, 'modified'),
(ACTION_BULK_EDIT, 'bulk edited'),
(ACTION_DELETE, 'deleted'),
(ACTION_BULK_DELETE, 'bulk deleted')
(ACTION_BULK_DELETE, 'bulk deleted'),
)
#
# Custom fields
#
class CustomFieldModel(object):
def cf(self):
@@ -93,6 +104,7 @@ class CustomFieldModel(object):
return OrderedDict([(field, None) for field in fields])
@python_2_unicode_compatible
class CustomField(models.Model):
obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
@@ -114,7 +126,7 @@ class CustomField(models.Model):
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
def __str__(self):
return self.label or self.name.replace('_', ' ').capitalize()
def serialize_value(self, value):
@@ -146,15 +158,13 @@ class CustomField(models.Model):
# Read date as YYYY-MM-DD
return date(*[int(n) for n in serialized_value.split('-')])
if self.type == CF_TYPE_SELECT:
try:
return self.choices.get(pk=int(serialized_value))
except CustomFieldChoice.DoesNotExist:
return None
return self.choices.get(pk=int(serialized_value))
return serialized_value
@python_2_unicode_compatible
class CustomFieldValue(models.Model):
field = models.ForeignKey('CustomField', related_name='values')
field = models.ForeignKey('CustomField', related_name='values', on_delete=models.CASCADE)
obj_type = models.ForeignKey(ContentType, related_name='+', on_delete=models.PROTECT)
obj_id = models.PositiveIntegerField()
obj = GenericForeignKey('obj_type', 'obj_id')
@@ -164,7 +174,7 @@ class CustomFieldValue(models.Model):
ordering = ['obj_type', 'obj_id']
unique_together = ['field', 'obj_type', 'obj_id']
def __unicode__(self):
def __str__(self):
return u'{} {}'.format(self.obj, self.field)
@property
@@ -183,6 +193,7 @@ class CustomFieldValue(models.Model):
super(CustomFieldValue, self).save(*args, **kwargs)
@python_2_unicode_compatible
class CustomFieldChoice(models.Model):
field = models.ForeignKey('CustomField', related_name='choices', limit_choices_to={'type': CF_TYPE_SELECT},
on_delete=models.CASCADE)
@@ -193,7 +204,7 @@ class CustomFieldChoice(models.Model):
ordering = ['field', 'weight', 'value']
unique_together = ['field', 'value']
def __unicode__(self):
def __str__(self):
return self.value
def clean(self):
@@ -207,6 +218,11 @@ class CustomFieldChoice(models.Model):
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
#
# Graphs
#
@python_2_unicode_compatible
class Graph(models.Model):
type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES)
weight = models.PositiveSmallIntegerField(default=1000)
@@ -217,7 +233,7 @@ class Graph(models.Model):
class Meta:
ordering = ['type', 'weight', 'name']
def __unicode__(self):
def __str__(self):
return self.name
def embed_url(self, obj):
@@ -231,8 +247,15 @@ class Graph(models.Model):
return template.render(Context({'obj': obj}))
#
# Export templates
#
@python_2_unicode_compatible
class ExportTemplate(models.Model):
content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS})
content_type = models.ForeignKey(
ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}, on_delete=models.CASCADE
)
name = models.CharField(max_length=100)
description = models.CharField(max_length=200, blank=True)
template_code = models.TextField()
@@ -245,7 +268,7 @@ class ExportTemplate(models.Model):
['content_type', 'name']
]
def __unicode__(self):
def __str__(self):
return u'{}: {}'.format(self.content_type, self.name)
def to_response(self, context_dict, filename):
@@ -264,10 +287,15 @@ class ExportTemplate(models.Model):
return response
#
# Topology maps
#
@python_2_unicode_compatible
class TopologyMap(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True)
site = models.ForeignKey('dcim.Site', related_name='topology_maps', blank=True, null=True, on_delete=models.CASCADE)
device_patterns = models.TextField(
help_text="Identify devices to include in the diagram using regular expressions, one per line. Each line will "
"result in a new tier of the drawing. Separate multiple regexes within a line using semicolons. "
@@ -278,7 +306,7 @@ class TopologyMap(models.Model):
class Meta:
ordering = ['name']
def __unicode__(self):
def __str__(self):
return self.name
@property
@@ -287,6 +315,131 @@ class TopologyMap(models.Model):
return None
return [line.strip() for line in self.device_patterns.split('\n')]
def render(self, img_format='png'):
from circuits.models import CircuitTermination
from dcim.models import CONNECTION_STATUS_CONNECTED, Device, InterfaceConnection
# Construct the graph
graph = graphviz.Graph()
graph.graph_attr['ranksep'] = '1'
for i, device_set in enumerate(self.device_sets):
subgraph = graphviz.Graph(name='sg{}'.format(i))
subgraph.graph_attr['rank'] = 'same'
# Add a pseudonode for each device_set to enforce hierarchical layout
subgraph.node('set{}'.format(i), label='', shape='none', width='0')
if i:
graph.edge('set{}'.format(i - 1), 'set{}'.format(i), style='invis')
# Add each device to the graph
devices = []
for query in device_set.split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query).select_related('device_role')
for d in devices:
bg_color = '#{}'.format(d.device_role.color)
fg_color = '#{}'.format(foreground_color(d.device_role.color))
subgraph.node(d.name, style='filled', fillcolor=bg_color, fontcolor=fg_color, fontname='sans')
# Add an invisible connection to each successive device in a set to enforce horizontal order
for j in range(0, len(devices) - 1):
subgraph.edge(devices[j].name, devices[j + 1].name, style='invis')
graph.subgraph(subgraph)
# Compile list of all devices
device_superset = Q()
for device_set in self.device_sets:
for query in device_set.split(';'): # Split regexes on semicolons
device_superset = device_superset | Q(name__regex=query)
# Add all interface connections to the graph
devices = Device.objects.filter(*(device_superset,))
connections = InterfaceConnection.objects.filter(
interface_a__device__in=devices, interface_b__device__in=devices
)
for c in connections:
style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
# Add all circuits to the graph
for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):
peer_termination = termination.get_peer_termination()
if peer_termination is not None and peer_termination.interface.device in devices:
graph.edge(termination.interface.device.name, peer_termination.interface.device.name, color='blue')
return graph.pipe(format=img_format)
#
# Image attachments
#
def image_upload(instance, filename):
path = 'image-attachments/'
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
extension = filename.rsplit('.')[-1]
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
filename = '.'.join([instance.name, extension])
elif instance.name:
filename = instance.name
return u'{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
@python_2_unicode_compatible
class ImageAttachment(models.Model):
"""
An uploaded image which is associated with an object.
"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
parent = GenericForeignKey('content_type', 'object_id')
image = models.ImageField(upload_to=image_upload, height_field='image_height', width_field='image_width')
image_height = models.PositiveSmallIntegerField()
image_width = models.PositiveSmallIntegerField()
name = models.CharField(max_length=50, blank=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
if self.name:
return self.name
filename = self.image.name.rsplit('/', 1)[-1]
return filename.split('_', 2)[2]
def delete(self, *args, **kwargs):
_name = self.image.name
super(ImageAttachment, self).delete(*args, **kwargs)
# Delete file from disk
self.image.delete(save=False)
# Deleting the file erases its name. We restore the image's filename here in case we still need to reference it
# before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.)
self.image.name = _name
@property
def size(self):
"""
Wrapper around `image.size` to suppress an OSError in case the file is inaccessible.
"""
try:
return self.image.size
except OSError:
return None
#
# User actions
#
class UserActionManager(models.Manager):
@@ -321,6 +474,9 @@ class UserActionManager(models.Manager):
def log_import(self, user, content_type, message=''):
self.log_bulk_action(user, content_type, ACTION_IMPORT, message)
def log_bulk_create(self, user, content_type, message=''):
self.log_bulk_action(user, content_type, ACTION_BULK_CREATE, message)
def log_bulk_edit(self, user, content_type, message=''):
self.log_bulk_action(user, content_type, ACTION_BULK_EDIT, message)
@@ -328,6 +484,7 @@ class UserActionManager(models.Manager):
self.log_bulk_action(user, content_type, ACTION_BULK_DELETE, message)
@python_2_unicode_compatible
class UserAction(models.Model):
"""
A record of an action (add, edit, or delete) performed on an object by a User.
@@ -344,13 +501,13 @@ class UserAction(models.Model):
class Meta:
ordering = ['-time']
def __unicode__(self):
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)
def icon(self):
if self.action in [ACTION_CREATE, ACTION_IMPORT]:
if self.action in [ACTION_CREATE, ACTION_BULK_CREATE, ACTION_IMPORT]:
return mark_safe('<i class="glyphicon glyphicon-plus text-success"></i>')
elif self.action in [ACTION_EDIT, ACTION_BULK_EDIT]:
return mark_safe('<i class="glyphicon glyphicon-pencil text-warning"></i>')

View File

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

View File

@@ -0,0 +1,168 @@
from rest_framework import status
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from dcim.models import Device
from extras.models import Graph, GRAPH_TYPE_SITE, ExportTemplate
from users.models import Token
from utilities.tests import HttpStatusMixin
class GraphTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
self.graph1 = Graph.objects.create(
type=GRAPH_TYPE_SITE, name='Test Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=GRAPH_TYPE_SITE, name='Test Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=GRAPH_TYPE_SITE, name='Test Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'
)
def test_get_graph(self):
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.graph1.name)
def test_list_graphs(self):
url = reverse('extras-api:graph-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_graph(self):
data = {
'type': GRAPH_TYPE_SITE,
'name': 'Test Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
}
url = reverse('extras-api:graph-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Graph.objects.count(), 4)
graph4 = Graph.objects.get(pk=response.data['id'])
self.assertEqual(graph4.type, data['type'])
self.assertEqual(graph4.name, data['name'])
self.assertEqual(graph4.source, data['source'])
def test_update_graph(self):
data = {
'type': GRAPH_TYPE_SITE,
'name': 'Test Graph X',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=99',
}
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Graph.objects.count(), 3)
graph1 = Graph.objects.get(pk=response.data['id'])
self.assertEqual(graph1.type, data['type'])
self.assertEqual(graph1.name, data['name'])
self.assertEqual(graph1.source, data['source'])
def test_delete_graph(self):
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Graph.objects.count(), 2)
class ExportTemplateTest(HttpStatusMixin, APITestCase):
def setUp(self):
user = User.objects.create(username='testuser', is_superuser=True)
token = Token.objects.create(user=user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
self.content_type = ContentType.objects.get_for_model(Device)
self.exporttemplate1 = ExportTemplate.objects.create(
content_type=self.content_type, name='Test Export Template 1',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
self.exporttemplate2 = ExportTemplate.objects.create(
content_type=self.content_type, name='Test Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
self.exporttemplate3 = ExportTemplate.objects.create(
content_type=self.content_type, name='Test Export Template 3',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
def test_get_exporttemplate(self):
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.exporttemplate1.name)
def test_list_exporttemplates(self):
url = reverse('extras-api:exporttemplate-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_exporttemplate(self):
data = {
'content_type': self.content_type.pk,
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}
url = reverse('extras-api:exporttemplate-list')
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ExportTemplate.objects.count(), 4)
exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
self.assertEqual(exporttemplate4.content_type_id, data['content_type'])
self.assertEqual(exporttemplate4.name, data['name'])
self.assertEqual(exporttemplate4.template_code, data['template_code'])
def test_update_exporttemplate(self):
data = {
'content_type': self.content_type.pk,
'name': 'Test Export Template X',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.put(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ExportTemplate.objects.count(), 3)
exporttemplate1 = ExportTemplate.objects.get(pk=response.data['id'])
self.assertEqual(exporttemplate1.name, data['name'])
self.assertEqual(exporttemplate1.template_code, data['template_code'])
def test_delete_exporttemplate(self):
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ExportTemplate.objects.count(), 2)

View File

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

13
netbox/extras/urls.py Normal file
View File

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

30
netbox/extras/views.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ from django.db import models
from .formfields import IPFormField
from .lookups import (
EndsWith, IEndsWith, IRegex, IStartsWith, NetContained, NetContainedOrEqual, NetContains, NetContainsOrEquals,
NetHost, Regex, StartsWith,
NetHost, NetHostContained, NetMaskLength, Regex, StartsWith,
)
@@ -66,7 +66,7 @@ IPNetworkField.register_lookup(NetContained)
IPNetworkField.register_lookup(NetContainedOrEqual)
IPNetworkField.register_lookup(NetContains)
IPNetworkField.register_lookup(NetContainsOrEquals)
IPNetworkField.register_lookup(NetHost)
IPNetworkField.register_lookup(NetMaskLength)
class IPAddressField(BaseIPField):
@@ -90,3 +90,5 @@ IPAddressField.register_lookup(NetContainedOrEqual)
IPAddressField.register_lookup(NetContains)
IPAddressField.register_lookup(NetContainsOrEquals)
IPAddressField.register_lookup(NetHost)
IPAddressField.register_lookup(NetHostContained)
IPAddressField.register_lookup(NetMaskLength)

View File

@@ -7,21 +7,20 @@ from django.db.models import Q
from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter
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):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
@@ -34,7 +33,9 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Tenant (slug)',
)
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(rd__icontains=value) |
@@ -43,10 +44,11 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = VRF
fields = ['rd']
fields = ['name', 'rd']
class RIRFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
class Meta:
model = RIR
@@ -54,8 +56,9 @@ class RIRFilter(django_filters.FilterSet):
class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
rir_id = django_filters.ModelMultipleChoiceFilter(
@@ -74,7 +77,9 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
model = Aggregate
fields = ['family', 'date_added']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
prefix = str(IPNetwork(value.strip()).cidr)
@@ -85,14 +90,19 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
parent = django_filters.MethodFilter(
action='search_by_parent',
parent = django_filters.CharFilter(
method='search_by_parent',
label='Parent prefix',
)
mask_length = django_filters.NumberFilter(
method='filter_mask_length',
label='Mask length',
)
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
@@ -146,12 +156,17 @@ 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, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
prefix = str(IPNetwork(value.strip()).cidr)
@@ -160,7 +175,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass
return queryset.filter(qs_filter)
def search_by_parent(self, queryset, value):
def search_by_parent(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
@@ -170,34 +185,26 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
except AddrFormatError:
return queryset.none()
def _tenant(self, queryset, value):
if str(value) == '':
def filter_mask_length(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(
Q(tenant__slug=value) |
Q(tenant__isnull=True, vrf__tenant__slug=value)
)
def _tenant_id(self, queryset, value):
try:
value = int(value)
except ValueError:
return queryset.none()
return queryset.filter(
Q(tenant__pk=value) |
Q(tenant__isnull=True, vrf__tenant__pk=value)
)
return queryset.filter(prefix__net_mask_length=value)
class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
parent = django_filters.MethodFilter(
action='search_by_parent',
parent = django_filters.CharFilter(
method='search_by_parent',
label='Parent prefix',
)
mask_length = django_filters.NumberFilter(
method='filter_mask_length',
label='Mask length',
)
vrf_id = NullableModelMultipleChoiceFilter(
name='vrf_id',
queryset=VRF.objects.all(),
@@ -236,12 +243,17 @@ 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 = ['q', 'family', 'status']
fields = ['family']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(description__icontains=value)
try:
ipaddress = str(IPNetwork(value.strip()))
@@ -250,25 +262,30 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass
return queryset.filter(qs_filter)
def search_by_parent(self, queryset, value):
def search_by_parent(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
query = str(IPNetwork(value).cidr)
return queryset.filter(address__net_contained_or_equal=query)
query = str(IPNetwork(value.strip()).cidr)
return queryset.filter(address__net_host_contained=query)
except AddrFormatError:
return queryset.none()
def filter_mask_length(self, queryset, name, value):
if not value:
return queryset
return queryset.filter(address__net_mask_length=value)
class VLANGroupFilter(django_filters.FilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site__slug',
site = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -276,20 +293,22 @@ class VLANGroupFilter(django_filters.FilterSet):
class Meta:
model = VLANGroup
fields = ['name']
class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter(
action='search',
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
site_id = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='site__slug',
site = NullableModelMultipleChoiceFilter(
name='site',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
@@ -305,15 +324,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Group',
)
name = django_filters.CharFilter(
name='name',
lookup_type='icontains',
label='Name',
)
vid = django_filters.NumberFilter(
name='vid',
label='VLAN number (1-4095)',
)
tenant_id = NullableModelMultipleChoiceFilter(
name='tenant',
queryset=Tenant.objects.all(),
@@ -336,15 +346,20 @@ 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 = ['status']
fields = ['name', 'vid']
def search(self, queryset, value):
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
try:
qs_filter |= Q(vid=int(value))
qs_filter |= Q(vid=int(value.strip()))
except ValueError:
pass
return queryset.filter(qs_filter)

View File

@@ -3,10 +3,11 @@ 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 (
@@ -21,16 +22,22 @@ IP_FAMILY_CHOICES = [
(6, 'IPv6'),
]
PREFIX_MASK_LENGTH_CHOICES = [
('', '---------'),
] + [(i, i) for i in range(1, 128)]
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",
}
@@ -55,6 +62,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:
@@ -63,6 +73,7 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VRF
q = forms.CharField(required=False, label='Search')
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug',
null_option=(0, None))
@@ -128,9 +139,13 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Aggregate
q = forms.CharField(required=False, label='Search')
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug',
label='RIR')
rir = FilterChoiceField(
queryset=RIR.objects.annotate(filter_count=Count('aggregates')),
to_field_name='slug',
label='RIR'
)
#
@@ -149,30 +164,27 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
# Prefixes
#
class PrefixForm(BootstrapMixin, CustomFieldForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'vlan'}))
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'].choices = []
class PrefixFromCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd',
@@ -199,28 +211,31 @@ 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):
@@ -241,6 +256,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:
@@ -256,19 +272,34 @@ def prefix_status_choices():
class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Prefix
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
'placeholder': 'Network',
q = forms.CharField(required=False, label='Search')
parent = forms.CharField(required=False, label='Parent prefix', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family')
mask_length = forms.ChoiceField(required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label='Mask length')
vrf = FilterChoiceField(
queryset=VRF.objects.annotate(filter_count=Count('prefixes')),
to_field_name='rd',
label='VRF',
null_option=(0, 'Global')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug',
null_option=(0, 'None'))
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
role = FilterChoiceField(
queryset=Role.objects.annotate(filter_count=Count('prefixes')),
to_field_name='slug',
null_option=(0, 'None')
)
expand = forms.BooleanField(required=False, label='Expand prefix hierarchy')
@@ -276,95 +307,183 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
# IP addresses
#
class IPAddressForm(BootstrapMixin, CustomFieldForm):
nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site',
widget=forms.Select(attrs={'filter-for': 'nat_device'}))
nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device',
widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'}))
livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch(
query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address')
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
interface_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
widget=forms.Select(
attrs={'filter-for': 'interface_rack'}
)
)
interface_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'interface_site'},
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{interface_site}}',
display_field='display_name',
attrs={'filter-for': 'interface_device', 'nullable': 'true'}
)
)
interface_device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'interface_site', 'rack': 'interface_rack'},
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
display_field='display_name',
attrs={'filter-for': 'interface'}
)
)
interface = ChainedModelChoiceField(
queryset=Interface.objects.all(),
chains={'device': 'interface_device'},
required=False,
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
)
)
nat_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
widget=forms.Select(
attrs={'filter-for': 'nat_device'}
)
)
nat_rack = ChainedModelChoiceField(
queryset=Rack.objects.all(),
chains={'site': 'nat_site'},
required=False,
label='Rack',
widget=APISelect(
api_url='/api/dcim/racks/?site_id={{interface_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_device', 'nullable': 'true'}
)
)
nat_device = ChainedModelChoiceField(
queryset=Device.objects.all(),
chains={'site': 'nat_site'},
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/?site_id={{nat_site}}',
display_field='display_name',
attrs={'filter-for': 'nat_inside'}
)
)
nat_inside = ChainedModelChoiceField(
queryset=IPAddress.objects.all(),
chains={'interface__device': 'nat_device'},
required=False,
label='IP Address',
widget=APISelect(
api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}',
display_field='address'
)
)
livesearch = forms.CharField(
required=False,
label='IP Address',
widget=Livesearch(
query_key='q',
query_url='ipam-api:ipaddress-list',
field_to_update='nat_inside',
obj_label='address'
)
)
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
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')
}
fields = [
'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_inside', 'tenant_group',
'tenant',
]
def __init__(self, *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
super(IPAddressForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
if self.instance.nat_inside:
# 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
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.rack.site.pk
self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk
self.fields['nat_device'].queryset = Device.objects.filter(
rack__site=nat_inside.interface.device.rack.site)
self.fields['nat_inside'].queryset = IPAddress.objects.filter(
interface__device=nat_inside.interface.device)
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:
self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk)
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
# 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(rack__site__pk=self.data['nat_site'])
elif self.initial.get('nat_site'):
self.fields['nat_device'].queryset = Device.objects.filter(rack__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 = []
return ipaddress
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 IPAddressPatternForm(BootstrapMixin, forms.Form):
pattern = ExpandableIPAddressField(label='Address pattern')
class IPAddressAssignForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False,
widget=forms.Select(attrs={'filter-for': 'rack'}))
rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False,
widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
display_field='display_name', attrs={'filter-for': 'device'}))
device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False,
widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}',
display_field='display_name', 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(), label='Interface',
widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/'))
set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False)
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = IPAddress
fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant']
def __init__(self, *args, **kwargs):
super(IPAddressAssignForm, self).__init__(*args, **kwargs)
self.fields['rack'].choices = []
self.fields['device'].choices = []
self.fields['interface'].choices = []
super(IPAddressBulkAddForm, self).__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
class IPAddressFromCSVForm(forms.ModelForm):
@@ -446,14 +565,23 @@ def ipaddress_status_choices():
class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = IPAddress
parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={
q = forms.CharField(required=False, label='Search')
parent = forms.CharField(required=False, label='Parent Prefix', widget=forms.TextInput(attrs={
'placeholder': 'Prefix',
}))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family')
vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd',
label='VRF', null_option=(0, 'Global'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug', null_option=(0, 'None'))
family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address family')
mask_length = forms.ChoiceField(required=False, choices=IPADDRESS_MASK_LENGTH_CHOICES, label='Mask length')
vrf = FilterChoiceField(
queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='rd',
label='VRF',
null_option=(0, 'Global')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
@@ -470,68 +598,93 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
to_field_name='slug',
null_option=(0, 'Global')
)
#
# 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(),
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': "The site at which this VLAN exists",
'site': "Leave blank if this VLAN spans multiple sites",
'group': "VLAN group (optional)",
'vid': "Configured VLAN ID",
'name': "Configured VLAN name",
'status': "Operational status of this VLAN",
'role': "The primary function of this VLAN",
}
widgets = {
'site': forms.Select(attrs={'filter-for': 'group'}),
}
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'].choices = []
class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), 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.'})
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_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.'})
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_name', '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 save(self, *args, **kwargs):
m = super(VLANFromCSVForm, self).save(commit=False)
vlan = super(VLANFromCSVForm, self).save(commit=False)
# Assign VLANGroup by site and name
if self.cleaned_data['group_name']:
vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
# Assign VLAN status by name
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
vlan.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
if kwargs.get('commit'):
m.save()
return m
vlan.save()
return vlan
class VLANImportForm(BootstrapMixin, BulkImportForm):
@@ -548,7 +701,7 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
description = forms.CharField(max_length=100, required=False)
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'description']
nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
def vlan_status_choices():
@@ -560,14 +713,28 @@ def vlan_status_choices():
class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = VLAN
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug')
group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group',
null_option=(0, 'None'))
tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'Global')
)
group_id = FilterChoiceField(
queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')),
label='VLAN group',
null_option=(0, 'None')
)
tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'None')
)
status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug',
null_option=(0, 'None'))
role = FilterChoiceField(
queryset=Role.objects.annotate(filter_count=Count('vlans')),
to_field_name='slug',
null_option=(0, 'None')
)
#

View File

@@ -1,4 +1,4 @@
from django.db.models import Lookup
from django.db.models import Lookup, Transform, IntegerField
from django.db.models.lookups import BuiltinLookup
@@ -87,3 +87,26 @@ class NetHost(Lookup):
rhs_params[0] = rhs_params[0].split('/')[0]
params = lhs_params + rhs_params
return 'HOST(%s) = %s' % (lhs, rhs), params
class NetHostContained(Lookup):
"""
Check for the host portion of an IP address without regard to its mask. This allows us to find e.g. 192.0.2.1/24
when specifying a parent prefix of 192.0.2.0/26.
"""
lookup_name = 'net_host_contained'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
class NetMaskLength(Transform):
lookup_name = 'net_mask_length'
function = 'MASKLEN'
@property
def output_field(self):
return IntegerField()

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-23 19:10
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0013_prefix_add_is_pool'),
]
operations = [
migrations.AlterField(
model_name='ipaddress',
name='status',
field=models.PositiveSmallIntegerField(choices=[(1, b'Active'), (2, b'Reserved'), (3, b'Deprecated'), (5, b'DHCP')], default=1, verbose_name=b'Status'),
),
]

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-02-21 18:45
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0014_ipaddress_status_add_deprecated'),
]
operations = [
migrations.AlterField(
model_name='vlan',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site'),
),
migrations.AlterField(
model_name='vlangroup',
name='site',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlan_groups', to='dcim.Site'),
),
]

View File

@@ -3,10 +3,11 @@ 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
from extras.models import CustomFieldModel, CustomFieldValue
@@ -36,10 +37,12 @@ PREFIX_STATUS_CHOICES = (
IPADDRESS_STATUS_ACTIVE = 1
IPADDRESS_STATUS_RESERVED = 2
IPADDRESS_STATUS_DEPRECATED = 3
IPADDRESS_STATUS_DHCP = 5
IPADDRESS_STATUS_CHOICES = (
(IPADDRESS_STATUS_ACTIVE, 'Active'),
(IPADDRESS_STATUS_RESERVED, 'Reserved'),
(IPADDRESS_STATUS_DEPRECATED, 'Deprecated'),
(IPADDRESS_STATUS_DHCP, 'DHCP')
)
@@ -70,6 +73,7 @@ IP_PROTOCOL_CHOICES = (
)
@python_2_unicode_compatible
class VRF(CreatedUpdatedModel, CustomFieldModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -89,7 +93,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'VRF'
verbose_name_plural = 'VRFs'
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
@@ -105,6 +109,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel):
])
@python_2_unicode_compatible
class RIR(models.Model):
"""
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@@ -120,13 +125,14 @@ class RIR(models.Model):
verbose_name = 'RIR'
verbose_name_plural = 'RIRs'
def __unicode__(self):
def __str__(self):
return self.name
def get_absolute_url(self):
return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
@python_2_unicode_compatible
class Aggregate(CreatedUpdatedModel, CustomFieldModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@@ -142,7 +148,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
class Meta:
ordering = ['family', 'prefix']
def __unicode__(self):
def __str__(self):
return str(self.prefix)
def get_absolute_url(self):
@@ -204,6 +210,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel):
return int(children_size / self.prefix.size * 100)
@python_2_unicode_compatible
class Role(models.Model):
"""
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
@@ -216,7 +223,7 @@ class Role(models.Model):
class Meta:
ordering = ['weight', 'name']
def __unicode__(self):
def __str__(self):
return self.name
@property
@@ -260,9 +267,10 @@ class PrefixQuerySet(NullsFirstQuerySet):
p.depth = len(stack) - 1
if limit is None:
return queryset
return filter(lambda p: p.depth <= limit, queryset)
return list(filter(lambda p: p.depth <= limit, queryset))
@python_2_unicode_compatible
class Prefix(CreatedUpdatedModel, CustomFieldModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@@ -292,7 +300,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
ordering = ['vrf', 'family', 'prefix']
verbose_name_plural = 'prefixes'
def __unicode__(self):
def __str__(self):
return str(self.prefix)
def get_absolute_url(self):
@@ -377,6 +385,7 @@ class IPAddressManager(models.Manager):
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
@python_2_unicode_compatible
class IPAddress(CreatedUpdatedModel, CustomFieldModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
@@ -409,7 +418,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'IP address'
verbose_name_plural = 'IP addresses'
def __unicode__(self):
def __str__(self):
return str(self.address)
def get_absolute_url(self):
@@ -469,13 +478,14 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return STATUS_CHOICE_CLASSES[self.status]
@python_2_unicode_compatible
class VLANGroup(models.Model):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
"""
name = models.CharField(max_length=50)
slug = models.SlugField()
site = models.ForeignKey('dcim.Site', related_name='vlan_groups')
site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.PROTECT, blank=True, null=True)
class Meta:
ordering = ['site', 'name']
@@ -486,13 +496,16 @@ class VLANGroup(models.Model):
verbose_name = 'VLAN group'
verbose_name_plural = 'VLAN groups'
def __unicode__(self):
def __str__(self):
if self.site is None:
return self.name
return u'{} - {}'.format(self.site.name, self.name)
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
@python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
@@ -502,7 +515,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
or more Prefixes assigned to it.
"""
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True)
group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
MinValueValidator(1),
@@ -524,8 +537,8 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
verbose_name = 'VLAN'
verbose_name_plural = 'VLANs'
def __unicode__(self):
return self.display_name
def __str__(self):
return self.display_name or super(VLAN, self).__str__()
def get_absolute_url(self):
return reverse('ipam:vlan', args=[self.pk])
@@ -540,7 +553,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
def to_csv(self):
return csv_format([
self.site.name,
self.site.name if self.site else None,
self.group.name if self.group else None,
self.vid,
self.name,
@@ -552,12 +565,15 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@property
def display_name(self):
return u'{} ({})'.format(self.vid, self.name)
if self.vid and self.name:
return u"{} ({})".format(self.vid, self.name)
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
@python_2_unicode_compatible
class Service(CreatedUpdatedModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
@@ -576,5 +592,5 @@ class Service(CreatedUpdatedModel):
ordering = ['device', 'protocol', 'port']
unique_together = ['device', 'protocol', 'port']
def __unicode__(self):
def __str__(self):
return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())

View File

@@ -1,7 +1,7 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, ToggleColumn
from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
@@ -76,6 +76,15 @@ IPADDRESS_LINK = """
{% endif %}
"""
IPADDRESS_DEVICE = """
{% if record.interface %}
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
({{ record.interface.name }})
{% else %}
&mdash;
{% endif %}
"""
VRF_LINK = """
{% if record.vrf %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
@@ -133,16 +142,25 @@ TENANT_LINK = """
class VRFTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name')
name = tables.LinkColumn()
rd = tables.Column(verbose_name='RD')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
description = tables.Column(verbose_name='Description')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
class Meta(BaseTable.Meta):
model = VRF
fields = ('pk', 'name', 'rd', 'tenant', 'description')
class VRFSearchTable(SearchTable):
name = tables.LinkColumn()
rd = tables.Column(verbose_name='RD')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
class Meta(SearchTable.Meta):
model = VRF
fields = ('name', 'rd', 'tenant', 'description')
#
# RIRs
#
@@ -177,18 +195,25 @@ class RIRTable(BaseTable):
class AggregateTable(BaseTable):
pk = ToggleColumn()
prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate')
rir = tables.Column(verbose_name='RIR')
prefix = tables.LinkColumn(verbose_name='Aggregate')
child_count = tables.Column(verbose_name='Prefixes')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
description = tables.Column(verbose_name='Description')
class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
class AggregateSearchTable(SearchTable):
prefix = tables.LinkColumn(verbose_name='Aggregate')
date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added')
class Meta(SearchTable.Meta):
model = Aggregate
fields = ('prefix', 'rir', 'date_added', 'description')
#
# Roles
#
@@ -212,14 +237,13 @@ class RoleTable(BaseTable):
class PrefixTable(BaseTable):
pk = ToggleColumn()
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix', attrs={'th': {'style': 'padding-left: 17px'}})
prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
tenant = tables.TemplateColumn(TENANT_LINK)
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role')
description = tables.Column(verbose_name='Description')
role = tables.TemplateColumn(PREFIX_ROLE_LINK)
class Meta(BaseTable.Meta):
model = Prefix
@@ -230,18 +254,32 @@ class PrefixTable(BaseTable):
class PrefixBriefTable(BaseTable):
prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix')
vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.Column(verbose_name='Role')
prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF)
vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
status = tables.TemplateColumn(STATUS_LABEL)
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')])
class Meta(BaseTable.Meta):
model = Prefix
fields = ('prefix', 'vrf', 'status', 'site', 'role')
fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role')
orderable = False
class PrefixSearchTable(SearchTable):
prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}})
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK)
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN')
role = tables.TemplateColumn(PREFIX_ROLE_LINK)
class Meta(SearchTable.Meta):
model = Prefix
fields = ('prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description')
#
# IPAddresses
#
@@ -249,17 +287,17 @@ class PrefixBriefTable(BaseTable):
class IPAddressTable(BaseTable):
pk = ToggleColumn()
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant')
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
description = tables.Column(verbose_name='Description')
tenant = tables.TemplateColumn(TENANT_LINK)
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 '',
}
@@ -267,17 +305,30 @@ class IPAddressTable(BaseTable):
class IPAddressBriefTable(BaseTable):
address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address')
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False,
verbose_name='Device')
interface = tables.Column(orderable=False, verbose_name='Interface')
nat_inside = tables.LinkColumn('ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False,
verbose_name='NAT (Inside)')
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
interface = tables.Column(orderable=False)
nat_inside = tables.LinkColumn(
'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)'
)
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'device', 'interface', 'nat_inside')
class IPAddressSearchTable(SearchTable):
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK)
device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False)
interface = tables.Column(orderable=False)
class Meta(SearchTable.Meta):
model = IPAddress
fields = ('address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description')
#
# VLAN groups
#
@@ -303,15 +354,26 @@ class VLANGroupTable(BaseTable):
class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
name = tables.Column(verbose_name='Name')
prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role')
description = tables.Column(verbose_name='Description')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
status = tables.TemplateColumn(STATUS_LABEL)
role = tables.TemplateColumn(VLAN_ROLE_LINK)
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
class VLANSearchTable(SearchTable):
vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
status = tables.TemplateColumn(STATUS_LABEL)
role = tables.TemplateColumn(VLAN_ROLE_LINK)
class Meta(SearchTable.Meta):
model = VLAN
fields = ('vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')

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