Compare commits

...

227 Commits

Author SHA1 Message Date
Jeremy Stretch
7a64404299 Merge pull request #1614 from digitalocean/develop
Release v2.2.2
2017-10-17 11:24:02 -04:00
Jeremy Stretch
2afa6ed2cb Release v2.2.2 2017-10-17 11:21:58 -04:00
Jeremy Stretch
34f1a9ebfb Fixes #1579: Devices already assigned to a cluster cannot be added to a different cluster 2017-10-17 09:59:35 -04:00
Jeremy Stretch
6f2f8697ae Fixes #1609: Added missing virtual_machine field to IP address interface serializer 2017-10-17 09:23:53 -04:00
Jeremy Stretch
6ec9d1d6ce Merge pull request #1598 from candlerb/candlerb/1498
Avoid creating repeated graph nodes where device matches multiple regexps
2017-10-16 17:16:46 -04:00
Jeremy Stretch
047f22e110 Fixes #1605: Added clusters and virtual machines to object list for global search 2017-10-16 16:44:15 -04:00
root
5fc3eac0f6 Avoid creating repeated graph nodes where device matches multiple regexps
Fixes #1498
2017-10-16 10:13:39 +00:00
Jeremy Stretch
34259d5d9d Removed deprecated xstr and expand_pattern functions 2017-10-13 14:29:55 -04:00
Jeremy Stretch
91b6ebb0c0 Closes #1580: Allow cluster assignment when bulk importing devices 2017-10-13 14:19:41 -04:00
Jeremy Stretch
60b4f1f89f Fixes #1585: Fixed slug-based filtering of virtual machines 2017-10-13 12:14:19 -04:00
Jeremy Stretch
023ff6834a Designated new Docker build repo; removed stale Heroku build repo 2017-10-13 11:50:06 -04:00
Jeremy Stretch
d00cab0b0a Merge branch 'develop' of github.com:digitalocean/netbox into develop 2017-10-13 10:54:24 -04:00
Jeremy Stretch
17493ff655 Closes #1587: Add primary IP column for virtual machines in global search results 2017-10-13 10:53:25 -04:00
Jeremy Stretch
6c27e6c4fe Fixes #1584: Colorized virtual machine role column 2017-10-13 10:45:34 -04:00
Jeremy Stretch
4cb0be4df3 Fixes #1582: Add virtual_machine attribute to IPAddress 2017-10-13 10:42:45 -04:00
Jeremy Stretch
40f555a3b6 Merge pull request #1545 from digitalocean/mdl-ldap-docs-formatting
Minor LDAP documentation formatting cleanup
2017-10-12 23:42:29 -04:00
Jeremy Stretch
881fdbe893 Post-release version bump 2017-10-12 16:39:51 -04:00
Jeremy Stretch
2bda399982 Merge pull request #1577 from digitalocean/develop
Release v2.2.1
2017-10-12 16:11:17 -04:00
Jeremy Stretch
75d840fa1a PEP8 fix 2017-10-12 16:07:13 -04:00
Jeremy Stretch
800bdd8fc5 Release v2.2.1 2017-10-12 16:04:01 -04:00
Jeremy Stretch
b04ade8060 Fixes #1576: Move PostgreSQL validation logic into the relevant migration 2017-10-12 16:02:15 -04:00
Jeremy Stretch
7f4d96f33e Post-release version bump 2017-10-12 14:01:52 -04:00
Jeremy Stretch
74731bc6ae Merge pull request #1575 from digitalocean/develop
Release v2.2.0
2017-10-12 14:01:28 -04:00
Jeremy Stretch
9d8daca54d Release v2.2.0 2017-10-12 13:54:04 -04:00
Jeremy Stretch
bb898b719f Added reports CLI documentation 2017-10-12 13:48:08 -04:00
Jeremy Stretch
3a0b57b50f Fixed typo 2017-10-12 13:47:44 -04:00
Ryan Breaker
38d2333165 Add meta tags for charset and convert some tabs to spaces for consistency (#1574)
* Add meta tag for charset.

* Add meta tag for charset in 500.html and convert some tabs to spaces for consistency.
2017-10-12 13:39:28 -04:00
Jeremy Stretch
a7f0b5adb3 Refreshed installation docs 2017-10-12 13:38:23 -04:00
Jeremy Stretch
109fff0fa6 Reorganized the docs 2017-10-12 10:59:39 -04:00
Jeremy Stretch
8c591e7248 Fixed up reports documentation 2017-10-11 17:05:01 -04:00
Jeremy Stretch
0dafa10e27 Merge branch 'develop-2.2' into develop
Conflicts:
	netbox/netbox/settings.py
2017-10-11 16:45:18 -04:00
Jeremy Stretch
60e3ff0bf0 Updated version number for v2.2 build 2017-10-11 16:42:44 -04:00
Jeremy Stretch
6052405eb7 Added info about API _choices endpoints 2017-10-11 16:41:16 -04:00
Jeremy Stretch
17a45109f4 Bypass PostgreSQL 9.4 check if database is inaccessible 2017-10-11 16:28:05 -04:00
Jeremy Stretch
392b45e6cb Added warning for PostgreSQL 9.4 requirement 2017-10-11 16:20:50 -04:00
Jeremy Stretch
f27e1ba885 Check that PostgreSQL is 9.4 or higher on initialization 2017-10-11 16:16:44 -04:00
Jeremy Stretch
990c9d6f7c Updated package requirements 2017-10-11 15:11:51 -04:00
Jeremy Stretch
b301b8f6f2 Branded the browsable API template 2017-10-11 14:47:43 -04:00
Jeremy Stretch
924cf9bfd4 Removed obsolete docker build file 2017-10-11 14:39:51 -04:00
Jeremy Stretch
7e6573d191 Added missing permission class to reports API viewset 2017-10-11 14:37:25 -04:00
Jeremy Stretch
fdae3a3f31 Introduced the REPORTS_ROOT config parameter; Python2 fixes 2017-10-11 14:03:35 -04:00
Jeremy Stretch
51194e20f2 Improved template for when no reports are found 2017-10-11 13:30:38 -04:00
Jeremy Stretch
043f2cb214 Tweaked PostgreSQL version number in example 2017-10-11 13:12:49 -04:00
Jeremy Stretch
651d462456 Post-release version bump 2017-10-11 13:04:02 -04:00
Jeremy Stretch
7cb287d6c6 Merge pull request #1572 from digitalocean/develop
Release v2.1.6
2017-10-11 13:02:32 -04:00
Jeremy Stretch
d16d89028f Release v2.1.6 2017-10-11 12:59:43 -04:00
Jeremy Stretch
baa277c592 Updated install docs to install pip using setuptools 2017-10-11 12:22:35 -04:00
Jeremy Stretch
007f930fed Merge branch 'develop' into develop-2.2
Conflicts:
	netbox/utilities/api.py
2017-10-11 10:47:21 -04:00
Jeremy Stretch
f253f164a3 Closes #1510: Added ability to search by name when adding devices to a cluster 2017-10-11 10:14:09 -04:00
Jeremy Stretch
abfe71bb04 #1556: Swapped label and value 2017-10-10 17:47:53 -04:00
Jeremy Stretch
0c645b12d1 Moved querysets to separate files; tweaked interface ordering logic (#1523) 2017-10-10 17:23:41 -04:00
Jeremy Stretch
f824d1eb3b Closes #1556: Added API endpoints listing static field choices for each app 2017-10-10 16:41:35 -04:00
Jeremy Stretch
afbbe1148f Moved core API classes out of utilities 2017-10-10 14:21:20 -04:00
Jeremy Stretch
8403e91fc9 Fixes #1567: Prompt user for session key when importing secrets 2017-10-10 13:22:40 -04:00
Jeremy Stretch
c300879995 Fixes #1527: Replace deprecated pycrypto library with pycryptodome 2017-10-09 17:40:15 -04:00
Jeremy Stretch
ea1d298eb3 Closes #494: Include asset tag in device info pop-up on rack elevation 2017-10-09 16:25:52 -04:00
Jeremy Stretch
d17b36519a Closes #1479: Added CARP IP address role 2017-10-09 16:03:45 -04:00
Jeremy Stretch
f4f0896470 Added bulk import view for secret roles 2017-10-09 15:56:17 -04:00
Jeremy Stretch
2c9b41db75 Added bulk import view for circuit types 2017-10-09 15:52:22 -04:00
Jeremy Stretch
a38cd449c5 Added bulk import views for cluster types and groups 2017-10-09 15:49:48 -04:00
Jeremy Stretch
977cad3830 Added bulk import views for RIRs, roles, and VLAN groups 2017-10-09 15:42:06 -04:00
Jeremy Stretch
987587b5f3 Added NAPALM driver to platform table; removed legacy RPC client 2017-10-09 15:30:48 -04:00
Jeremy Stretch
9351f686b1 Added bulk import views for rack roles, device roles, and platforms 2017-10-09 15:28:46 -04:00
Jeremy Stretch
42e557bd3f Added bulk import view for tenant groups 2017-10-09 15:09:40 -04:00
Jeremy Stretch
35c2c8e8de #1444: Added a serial number field to the rack model 2017-10-09 15:01:57 -04:00
Jeremy Stretch
960e1d49c2 Closes #1551: Added CPAK, CFP2, and CFP4 100GE interface form factors 2017-10-09 14:45:10 -04:00
Jeremy Stretch
100d979d3e Fixes #1554: Don't require form_factor when creating an interface assigned to a virtual machine 2017-10-09 14:27:31 -04:00
Jeremy Stretch
b734599f40 Closes #1557: Added filtering for virtual machine interfaces 2017-10-09 13:59:53 -04:00
Jeremy Stretch
b41b4fc584 Closes #1506: Extended rack facility ID field from 30 to 50 characters 2017-10-09 13:44:22 -04:00
Jeremy Stretch
b69bf58c63 Closes #1548: Automatically populate tenant assignment when adding an IP address from the prefix view 2017-10-09 13:40:28 -04:00
Jeremy Stretch
a73e598a6e #1561: Selected related primary IPs for devices under global search 2017-10-09 13:32:30 -04:00
huzichunjohn
af2de6776d Closes #1561: Add Primary IP for a device to Search Results (#1565) 2017-10-09 13:28:39 -04:00
Jeremy Stretch
98195c9e74 Fixes #1550: Corrected interface connections link in navigation menu 2017-10-06 15:39:39 -04:00
Jeremy Stretch
97188ad85b Fixes #1563: Made necessary updates for Django REST Framework v3.7.0 2017-10-06 15:26:56 -04:00
Jeremy Stretch
9c673d2a2e Updated the docs to call out PostgreSQL 9.4+ as a requirement 2017-09-29 14:36:55 -04:00
Jeremy Stretch
77182d8711 Redesigned the navigation menus 2017-09-29 14:27:42 -04:00
Matt Layher
a01c9ff379 Minor LDAP documentation formatting cleanup 2017-09-29 13:22:42 -04:00
Jeremy Stretch
32399b0cb5 Merge branch 'develop-2.2' of github.com:digitalocean/netbox into develop-2.2 2017-09-29 12:54:38 -04:00
Jeremy Stretch
fe3cdb1e20 Merge pull request #1359 from paravoid/develop-2.1
Add bulk (CSV) import/export for more object types
2017-09-29 12:35:36 -04:00
Jeremy Stretch
3d25cecc69 Merge branch 'develop-2.2' into develop-2.1 2017-09-29 12:30:36 -04:00
Jeremy Stretch
068c0ff36c Merge branch 'develop' into develop-2.1 2017-09-29 12:26:54 -04:00
Jeremy Stretch
86a298792a Corrected default return URL for BulkComponentCreateView 2017-09-29 12:15:14 -04:00
Jeremy Stretch
a6599874db #1493: Extended DeviceRole to include a toggle indicating applicability to virtual machines 2017-09-29 12:11:20 -04:00
Jeremy Stretch
0deae84ecb Renamed column 2017-09-29 11:50:26 -04:00
Jeremy Stretch
6243fbfd0d Closes #1493: Added functional roles for virtual machines 2017-09-29 11:13:41 -04:00
Jeremy Stretch
f49d7ce1da Removed deprecated NETBOX_USERNAME and NETBOX_PASSWORD configuration parameters 2017-09-29 10:18:18 -04:00
Jeremy Stretch
75fed52bb6 Fixed YAML 2017-09-28 17:55:05 -04:00
Jeremy Stretch
90ec177360 Troubleshooting Travis CI 2017-09-28 17:47:11 -04:00
Jeremy Stretch
667eadb430 Removed Docker bits from CI build 2017-09-28 17:36:05 -04:00
Jeremy Stretch
afbe0bc307 Merge pull request #1544 from digitalocean/reports
Closes #1511: Implemented reports
2017-09-28 17:16:10 -04:00
Jeremy Stretch
c65af6a74f Trying to get Travis to run PostgreSQL 9.4 2017-09-28 17:03:47 -04:00
Jeremy Stretch
669aee2d73 Bumped psycopg2 hoping to fix jsonb errors in CI 2017-09-28 16:43:35 -04:00
Jeremy Stretch
67f0dfa449 We need PostgreSQL 9.4 or higher for jsonb fields 2017-09-28 16:32:59 -04:00
Jeremy Stretch
e630a1ace1 Added docs for reports 2017-09-28 16:25:42 -04:00
Jeremy Stretch
21485ca6e2 Restrict the running of reports via API to POST requests 2017-09-28 16:04:37 -04:00
Jeremy Stretch
87fb4af5cb Cosmetic cleanup 2017-09-28 15:18:40 -04:00
Jeremy Stretch
e86ec75513 Fixed 'failure' field name 2017-09-28 14:57:54 -04:00
Jeremy Stretch
1ad099d9fd Added nav menu link to reports list 2017-09-28 14:54:11 -04:00
Jeremy Stretch
f9a677c1a3 Bugfixes 2017-09-28 13:36:50 -04:00
Jeremy Stretch
6c6b67330f Expanded report view 2017-09-28 13:35:18 -04:00
Jeremy Stretch
2b33e78fd3 Added a run view for reports 2017-09-28 12:51:10 -04:00
Jeremy Stretch
b65e9fe0f5 Fixed runreport management command 2017-09-28 12:50:52 -04:00
Jeremy Stretch
571b817f04 Moved ReportResult creation into Report.run() 2017-09-28 12:50:32 -04:00
Jeremy Stretch
e2b2815545 Fixes #1542: Added cluster field to device view 2017-09-28 10:21:41 -04:00
Jeremy Stretch
0ea58ab268 Fixes #1543: Added missing virtual machine field to IP address and service filters 2017-09-28 09:29:12 -04:00
Jeremy Stretch
2fbb39bf6f Started adding a view for individual reports 2017-09-27 17:39:22 -04:00
Jeremy Stretch
f4c87b3739 Removed custom permission 2017-09-26 17:31:16 -04:00
Jeremy Stretch
696d91daa3 Prettied up the reports list 2017-09-26 17:17:28 -04:00
Jeremy Stretch
9a1781e6e7 Added url field for nested report results 2017-09-26 16:55:25 -04:00
Jeremy Stretch
3395b51086 Cleaned up the API quite a bit 2017-09-26 16:36:43 -04:00
Jeremy Stretch
a3a77a0bb6 Fixes #1540: Added missing 'cluster' field to WritableDeviceSerializer 2017-09-26 15:45:14 -04:00
Jeremy Stretch
d35a2b0faa Extended reports API 2017-09-25 17:27:58 -04:00
Jeremy Stretch
88c57d002d Added navigation panel 2017-09-25 16:22:50 -04:00
Jeremy Stretch
0d9ff907a8 Merge branch 'develop' of github.com:digitalocean/netbox into develop-2.2 2017-09-25 15:12:47 -04:00
Jeremy Stretch
02d8897b82 Post-release version bump 2017-09-25 14:53:10 -04:00
Jeremy Stretch
aa8f734bd1 Merge pull request #1537 from digitalocean/develop
Release v2.1.5
2017-09-25 14:52:43 -04:00
Jeremy Stretch
0015484679 Release v2.1.5 2017-09-25 14:50:45 -04:00
Jeremy Stretch
aa5aa798e6 Fixes #1486: Ignore subinterface IDs when validating LLDP neighbor connections 2017-09-25 14:33:32 -04:00
Jeremy Stretch
63a4a70420 #1499: Added utilization graph to child prefixes for aggregates 2017-09-25 13:39:03 -04:00
Jeremy Stretch
2badb04a03 Fixes #1489: Corrected server error on validation of empty required custom field 2017-09-25 13:37:11 -04:00
Jeremy Stretch
a328e12642 Closes #1536: Improved formatting of aggregate prefix statistics 2017-09-25 12:14:55 -04:00
Jeremy Stretch
ce9f1eb201 Closes #1499: Added utilization graph to child prefixes table 2017-09-22 21:17:46 -04:00
Jeremy Stretch
b454ac9ce5 Merge pull request #1524 from larsuhartmann/develop
mentioned support of nested groups in LDAP installation documentation.
2017-09-22 16:19:32 -04:00
Jeremy Stretch
a010f7439d More work on #1523 (natural ordering for interfaces) 2017-09-22 16:18:43 -04:00
Jeremy Stretch
d7b0ba57e0 Merge pull request #1526 from tarkatronic/order_naturally_enhancements
Fixes #1523 - Interface.objects.order_naturally() enhancements
2017-09-22 14:12:43 -04:00
Jeremy Stretch
2ca161f3d8 Closes #1509: Extended cluster model to allow site assignment 2017-09-22 12:53:09 -04:00
Jeremy Stretch
79fdf641c0 Implemented rough UI for accessing report results 2017-09-22 12:11:10 -04:00
Joey Wilhelm
89194c067b Another test case to ensure subinterface ordering 2017-09-21 17:21:21 -07:00
Jeremy Stretch
b5ab498e75 Initial work on reports API 2017-09-21 16:32:05 -04:00
Joey Wilhelm
126a5e5e4e Fix order_naturally with unbalanced names and use RawSQL instead of extra 2017-09-21 11:00:55 -07:00
Jeremy Stretch
16d1f9aca8 Tweaked report run logic 2017-09-21 13:49:04 -04:00
Lars Hartmann
466ab0b404 mentioned support of nested groups in LDAP installation documentation. 2017-09-21 16:36:25 +02:00
Jeremy Stretch
a52a871a94 Fixes #1522: Removed object create/edit forms from the browsable API 2017-09-20 16:54:27 -04:00
Jeremy Stretch
4f9379a5e2 Cleaned up device interfaces/IPs table 2017-09-20 14:44:51 -04:00
Jeremy Stretch
4cfad2ef3b Added virtualization models to nbshell and fixed up status constants 2017-09-20 14:03:56 -04:00
Jeremy Stretch
6e81b0ac32 Merge pull request #1517 from huzichunjohn/issue_1514
Fixes #1514: No field to edit Cluster comments
2017-09-20 11:53:43 -04:00
Jeremy Stretch
d87023e1ce Limited virtual machine interfaces to type "virtual" (removed LAG type) 2017-09-20 11:47:37 -04:00
Jeremy Stretch
e31d2c9857 Fixes #1520: Redirect on GET request to bulk edit/delete views 2017-09-20 09:56:20 -04:00
johnhu
2174ccf016 Fixes #1514: No field to edit Cluster comments 2017-09-20 11:48:49 +00:00
Jeremy Stretch
8f1607e010 Initial work on reports 2017-09-19 17:47:42 -04:00
Jeremy Stretch
e93129f1ae Enabled export templates for clusters and virtual machines 2017-09-19 11:31:29 -04:00
Jeremy Stretch
35b4e190d9 Fixes #1508: Enabled creating custom fields for clusters and virtual machines via the admin UI 2017-09-19 10:40:38 -04:00
Ryan Breaker
b252f52f8e Added missing package for CentOS and Fedora (#1470)
Without the added package, installation fails during `pip install -r requirements.txt` on the pycrypto package for me in CentOS 6 and 7 and Fedora.
2017-09-18 16:37:51 -04:00
Chris Howells
eba30c4d79 Document the PATCH method. Closes #1481. (#1482) 2017-09-18 16:37:09 -04:00
Jeremy Stretch
ae1662e433 Fixes #1507: Fixed error when creating the next available IP from a prefix within a VRF 2017-09-18 16:34:28 -04:00
Jeremy Stretch
512a3bf09a Fixed #1500: Allow assigning IP addresses to virtual machines during CSV bulk import 2017-09-18 13:30:20 -04:00
Jeremy Stretch
9927ce14d3 Fixes #1502: Fixed CSV export for clusters and virtual machines 2017-09-18 13:12:58 -04:00
Jeremy Stretch
e5e169f476 Fixes #1504: Added missing vcpus/memory/disk fields to virtual machine API serializer 2017-09-18 12:54:50 -04:00
Jeremy Stretch
81df837a33 Closes #1485: Added LOGIN_BANNER configuration setting to display a banner on the login page 2017-09-15 15:28:09 -04:00
Jeremy Stretch
b84b526a43 Closes #1484: Added individual "add VLAN" buttons on the VLAN groups list 2017-09-15 15:14:35 -04:00
Jeremy Stretch
5fc92ab0e6 Removed old RPC-based LLDP neighbors code 2017-09-15 14:07:09 -04:00
Jeremy Stretch
039c2a6d57 Closes #1496: Added primary IP column to virtual machine list 2017-09-15 11:47:29 -04:00
Jeremy Stretch
d053422657 Fixes #1497: Added cluster_id filter 2017-09-15 11:36:06 -04:00
Jeremy Stretch
90d9eb3520 Closes #1490: Rearrange order of items in the virtualization nav menu 2017-09-15 11:08:23 -04:00
Jeremy Stretch
3466da4338 Merge branch 'develop' into develop-2.2
Conflicts:
	netbox/dcim/forms.py
2017-09-14 14:58:45 -04:00
Jeremy Stretch
17c1a1e465 Use add_blank_choice() to prepend a null choice to field options 2017-09-14 14:45:45 -04:00
Jeremy Stretch
6872ab0e14 Added a status field for virtual machines 2017-09-14 14:35:34 -04:00
Jeremy Stretch
136d16b7fd Don't add a placeholder option to multiselect widgets 2017-09-12 14:14:08 -04:00
Jeremy Stretch
d651deb31c Renamed form_class attribute to model_form for consistency 2017-09-12 13:55:11 -04:00
Jeremy Stretch
8fb37233f4 Started documenting utility views 2017-09-12 13:53:15 -04:00
Jeremy Stretch
700194b80d Implemented bulk interface creation for virtual machines 2017-09-12 12:49:01 -04:00
Jeremy Stretch
ef2dd673ec Finished bulk edit/delete views 2017-09-11 16:14:05 -04:00
Jeremy Stretch
7a3adca771 Added device count to clusters table 2017-09-11 15:51:03 -04:00
Jeremy Stretch
f66b0b7fea Finished implementing CSV imports 2017-09-11 15:42:18 -04:00
huzichunjohn
b6df0209ba Fixes #1472: Secret truncated when using '<' character (#1477) 2017-09-11 10:55:04 -04:00
Jeremy Stretch
6b53d263fe Fixes #1469: Allow a NAT IP to be assigned as the primary IP for a device 2017-09-01 13:00:44 -04:00
Jeremy Stretch
00024240bb Merge branch 'virtualization' into develop-2.2 2017-09-01 12:29:12 -04:00
Jeremy Stretch
237a889f54 Preserve query parameters on redirect after clicking "add another" 2017-09-01 12:28:34 -04:00
Jeremy Stretch
f5c265b7b5 Modified form action to preserve query parameters 2017-09-01 12:25:57 -04:00
Jeremy Stretch
1341ab5703 Fixed selection of sites not assigned to a region 2017-09-01 10:10:10 -04:00
Jeremy Stretch
3ded8196c4 Fixed IP toggle button 2017-09-01 10:09:52 -04:00
Jeremy Stretch
cbc239ceaa Added virtualization API tests 2017-08-31 14:03:12 -04:00
Jeremy Stretch
a6b43baafe Added documentation for virtualization data model 2017-08-31 13:22:12 -04:00
Jeremy Stretch
ba0a261840 Removed extraneous import 2017-08-31 13:04:56 -04:00
Jeremy Stretch
3bb0d523d3 Enabled services on virtual machines 2017-08-31 12:50:35 -04:00
Jeremy Stretch
85cb333a5d Added virtualization to API root 2017-08-31 11:36:29 -04:00
Jeremy Stretch
2abb073b3a Regenerated migrations 2017-08-31 10:17:59 -04:00
Jeremy Stretch
0634386b2e Merge branch 'develop' into virtualization 2017-08-31 10:14:50 -04:00
Jeremy Stretch
372e9335b1 Post-release version bump 2017-08-30 14:44:55 -04:00
Jeremy Stretch
f6d1163ddd Merge pull request #1461 from digitalocean/develop
Release v2.1.4
2017-08-30 14:43:01 -04:00
Jeremy Stretch
e6b6082a2b Release v2.1.4 2017-08-30 14:40:52 -04:00
Jeremy Stretch
e0ee0b9254 Closes #1460: Hostnames with no domain are now acceptable in custom URL fields 2017-08-30 14:08:39 -04:00
Jeremy Stretch
fde47133da Fix global search placeholder text 2017-08-30 12:07:11 -04:00
Jeremy Stretch
643f64df3f Closes #1341: Added a MEDIA_ROOT configuration setting to specify where uploaded files are stored on disk 2017-08-30 11:11:59 -04:00
Jeremy Stretch
a6ec1ba23b Closes #1376: Ignore anycast when detecting duplicate IPs 2017-08-30 10:46:30 -04:00
Jeremy Stretch
ae231b1d1b Moved circuit speed humanization to a template tag 2017-08-29 22:42:06 -04:00
Jeremy Stretch
ecdf66c454 Closes #1326: Added dropdown widget with common values for circuit speed fields 2017-08-29 22:29:30 -04:00
Jeremy Stretch
aaca28c1a6 Closes #1402: Increased max length of name field for device components 2017-08-29 17:27:30 -04:00
Jeremy Stretch
f0d8fdf2d8 Closes #1431: Added interface form factor for 10GBASE-CX4 2017-08-29 17:02:09 -04:00
Jeremy Stretch
c9288a052a Closes #1432: Added a commit_rate field to the circuits list search form 2017-08-29 16:48:11 -04:00
Jeremy Stretch
4b3a425888 Fixed role link on IP address view 2017-08-29 16:14:32 -04:00
Jeremy Stretch
71069ced7f Added platform info to VM view 2017-08-29 16:12:52 -04:00
Jeremy Stretch
1308839a41 Fixes #1458: Corrected permission name on prefix/VLAN roles list 2017-08-29 15:36:51 -04:00
Jeremy Stretch
6d849ad5f6 Deleted obsolete templates 2017-08-29 15:31:11 -04:00
Jeremy Stretch
fa95191792 Allowed assigning an IP address to either a device or a VM 2017-08-29 15:26:35 -04:00
Jeremy Stretch
e945aafd7b Added fitlers to return the verbose model name of an object 2017-08-29 15:10:52 -04:00
Jeremy Stretch
e9f75adddb Ditched VMInterface in favor of reusing dcim.Interface 2017-08-29 14:24:58 -04:00
Jeremy Stretch
3e4b66675b Rearranged circuits navigation dropdown 2017-08-24 12:51:50 -04:00
Jeremy Stretch
6f8d3f7a57 Fixes #1443: Fixed API validation error involving custom field data 2017-08-22 11:31:33 -04:00
Jeremy Stretch
eb994a691a Added style to BaseTable's empty_text 2017-08-21 17:38:27 -04:00
Jeremy Stretch
6ebd8e78c8 Added VMs to tenant view 2017-08-21 17:21:02 -04:00
Jeremy Stretch
daa947e7eb Added virtualization models to home/search 2017-08-21 17:17:01 -04:00
Jeremy Stretch
b117130aac Added icons 2017-08-21 17:02:35 -04:00
Jeremy Stretch
4587aba1d4 Added views to add/remove hosts to/from clusters 2017-08-21 16:53:36 -04:00
Jeremy Stretch
ae73e6f04d Removed task list formatting 2017-08-21 11:10:07 -04:00
Jeremy Stretch
5930a64203 Converted IPAddress.interface to a GenericForeignKey 2017-08-18 16:57:20 -04:00
Jeremy Stretch
97536c4e9b Genericized component edit/delete views 2017-08-18 14:43:11 -04:00
Jeremy Stretch
e81e33af38 Added views for VM interfaces 2017-08-18 14:37:19 -04:00
Jeremy Stretch
a7c56eab86 Moved DeviceComponentForm to utilities.forms 2017-08-18 13:10:19 -04:00
Jeremy Stretch
6e580a731e Added template names to device component create views 2017-08-18 12:29:39 -04:00
Jeremy Stretch
7843a6bf77 Fixed devicetype_id filter for DeviceType components 2017-08-18 10:27:43 -04:00
Jeremy Stretch
fa6d933544 Converted list of issue types to a task list 2017-08-17 15:14:20 -04:00
Jeremy Stretch
83085d08ca Revised the GitHub issue template 2017-08-17 15:11:55 -04:00
Jeremy Stretch
a02055e9b5 Reworked relationship between devices and clusters 2017-08-16 17:21:34 -04:00
Jeremy Stretch
9acd792abe Added virtualization filters 2017-08-16 17:00:17 -04:00
Jeremy Stretch
4ef55502b4 Updates for v2.1.3 and expanded VirtualMachine model 2017-08-16 15:25:33 -04:00
Jeremy Stretch
3009863877 Merge branch 'develop' into virtualization 2017-08-16 15:08:37 -04:00
Jeremy Stretch
70a6b171d0 Fixes #1429: Fixed uptime formatting on device status page 2017-08-16 11:20:52 -04:00
Jeremy Stretch
9a25b5458d Merge pull request #1430 from xmudrii/fix-docs
docs: web-server link to new ssl tutorials for 16.04
2017-08-15 21:39:59 -04:00
Marko Mudrinić
416aeb4072 docs: web-server link to new ssl tutorials for 16.04 2017-08-15 23:39:10 +02:00
Jeremy Stretch
0c1594b8f8 Post-release version bump 2017-08-15 15:53:10 -04:00
Jeremy Stretch
b67e3ff957 Implemented initial virtualization API 2017-08-08 16:33:34 -04:00
Jeremy Stretch
d06813f528 Initial work on virtualization support (#142) 2017-08-04 17:02:52 -04:00
Faidon Liambotis
4544893b4c Allow import/export of device types (#1347) 2017-07-25 17:21:34 +03:00
Faidon Liambotis
f30fa925ea Allow import/export of manufacturers (#1347) 2017-07-25 17:21:34 +03:00
Faidon Liambotis
beb91559e5 Allow import/export of rack groups (#1347) 2017-07-25 17:21:34 +03:00
Faidon Liambotis
fbc5e74318 Allow import/export of regions (#1347) 2017-07-25 17:21:34 +03:00
180 changed files with 7031 additions and 1604 deletions

View File

@@ -1,28 +1,41 @@
<!--
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:
Before opening a new issue, please search through the existing issues to
see if your topic has already been addressed. Note that you may need to
remove the "is:open" filter from the search bar to include closed issues.
Check the appropriate type for your issue below by placing an x between the
brackets. If none of the below apply, please raise your issue for
discussion on our 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:
Please note that issues which do not fall under any of the below categories
will be closed.
--->
### Issue type
[ ] Feature request <!-- Requesting the implementation of a new feature -->
[ ] Bug report <!-- Reporting unexpected or erroneous behavior -->
[ ] Documentation <!-- Proposing a modification to the documentation -->
<!--
If filing a bug, please indicate the version of Python and NetBox
you are running. (This is not necessary for feature requests.)
Please describe the environment in which you are running NetBox. (Be sure
to verify that you are running the latest stable release of NetBox before
submitting a bug report.)
-->
**Python version:**
**NetBox version:**
### Environment
* Python version: <!-- Example: 3.5.4 -->
* NetBox version: <!-- Example: 2.1.3 -->
<!--
If filing a bug, please record the exact steps taken to reproduce
the bug and any errors messages that are generated.
BUG REPORTS must include:
* A list of the steps needed to reproduce the bug
* A description of the expected behavior
* Any relevant error messages (screenshots may also help)
If filing a feature request, please precisely describe the data
model or workflow you would like to see implemented, and provide a
use case.
FEATURE REQUESTS must include:
* A detailed description of the proposed functionality
* A use case for the new feature
* A rough description of any necessary changes to the database schema
* Any relevant third-party libraries which would be needed
-->
### Description

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
*.pyc
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/reports/*
!/netbox/reports/__init__.py
/netbox/static
.idea
/*.sh

View File

@@ -1,11 +1,8 @@
sudo: required
services:
- docker
env:
- DOCKER_TAG=$TRAVIS_TAG
- postgresql
addons:
postgresql: "9.4"
language: python
python:
- "2.7"
@@ -13,9 +10,8 @@ python:
install:
- pip install -r requirements.txt
- pip install pep8
before_script:
- psql --version
- psql -U postgres -c 'SELECT version();'
script:
- ./scripts/cibuild.sh
after_success:
- if [ ! -z "$TRAVIS_TAG" ] && [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
./scripts/docker-build.sh;
fi

View File

@@ -31,6 +31,5 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for inst
## Alternative Installations
* [Docker container](https://github.com/digitalocean/netbox-docker)
* [Heroku deployment](https://heroku.com/deploy?template=https://github.com/BILDQUADRAT/netbox/tree/heroku) (via [@mraerino](https://github.com/BILDQUADRAT/netbox/tree/heroku))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant)
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))

View File

@@ -4,7 +4,8 @@ Supported HTTP methods:
* `GET`: Retrieve an object or list of objects
* `POST`: Create a new object
* `PUT`: Update an existing object
* `PUT`: Update an existing object, all mandatory fields must be specified
* `PATCH`: Updates an existing object, only specifiying the field to be changed
* `DELETE`: Delete an existing object
To authenticate a request, attach your token in an `Authorization` header:
@@ -104,12 +105,19 @@ $ curl -X POST -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0
### 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.
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"}'
```
### Modify an object by changing a field
Make an authenticated `PATCH` request to the device endpoint. With `PATCH`, unlike `POST` and `PUT`, we only specify the field that is being changed. In this example, we add a serial number to a device.
```
$ curl -X PATCH -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f9cdc0" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/2549/ --data '{"serial": "FTX1123A090"}'
```
### Delete an existing site
Send an authenticated `DELETE` request to the site detail endpoint.

View File

@@ -6,7 +6,7 @@ REST stands for [representational state transfer](https://en.wikipedia.org/wiki/
* `GET`: Retrieve an object or list of objects
* `POST`: Create an object
* `PUT` / `PATCH`: Modify an existing object
* `PUT` / `PATCH`: Modify an existing object. `PUT` requires all mandatory fields to be specified, while `PATCH` only expects the field that is being modified to be specified.
* `DELETE`: Delete an existing object
The NetBox API represents all objects in [JavaScript Object Notation (JSON)](http://www.json.org/). This makes it very easy to interact with NetBox data on the command line with common tools. For example, we can request an IP address from NetBox and output the JSON using `curl` and `jq`. (Piping the output through `jq` isn't strictly required but makes it much easier to read.)
@@ -88,10 +88,10 @@ The base serializer is used to represent the default view of a model. This inclu
"vid": 101,
"name": "Users-Floor1",
"tenant": null,
"status": [
1,
"Active"
],
"status": {
"value": 1,
"label": "Active"
},
"role": {
"id": 9,
"url": "http://localhost:8000/api/ipam/roles/9/",
@@ -122,6 +122,37 @@ When a base serializer includes one or more nested serializers, the hierarchical
}
```
## Static Choice Fields
Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL.
Each choice includes a human-friendly label and its corresponding numeric value. For example, `GET /api/ipam/_choices/prefix:status/` will return:
```
[
{
"value": 0,
"label": "Container"
},
{
"value": 1,
"label": "Active"
},
{
"value": 2,
"label": "Reserved"
},
{
"value": 3,
"label": "Deprecated"
}
]
```
Thus, to set a prefix's status to "Reserved," it would be assigned the integer `2`.
A request for `GET /api/ipam/_choices/` will return choices for _all_ fields belonging to models within the IPAM app.
# 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:

View File

@@ -17,7 +17,7 @@ ADMINS = [
## BANNER_BOTTOM
Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. To replicate the content of the top banner in the bottom banner, set:
Setting these variables will display content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set:
```
BANNER_TOP = 'Your banner text'
@@ -26,6 +26,12 @@ BANNER_BOTTOM = BANNER_TOP
---
## BANNER_LOGIN
The value of this variable will be displayed on the login page above the login form. HTML is allowed.
---
## BASE_PATH
Default: None
@@ -135,6 +141,14 @@ An API consumer can request an arbitrary number of objects by appending the "lim
---
## MEDIA_ROOT
Default: $BASE_DIR/netbox/media/
The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path.
---
## NAPALM_USERNAME
## NAPALM_PASSWORD
@@ -177,14 +191,6 @@ The amount of time (in seconds) to wait for NAPALM to connect to a device.
---
## NETBOX_USERNAME (Deprecated)
## NETBOX_PASSWORD (Deprecated)
These settings have been deprecated and will be removed in NetBox v2.2. Please use `NAPALM_USERNAME` and `NAPALM_PASSWORD` instead.
---
## PAGINATE_COUNT
Default: 50
@@ -201,6 +207,14 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
---
## REPORTS_ROOT
Default: $BASE_DIR/netbox/reports/
The file path to the location where custom reports will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path.
---
## TIME_ZONE
Default: UTC

View File

@@ -96,4 +96,4 @@ VLAN groups can be employed for administrative organization within NetBox. Each
# 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 assigned IP address.)
A service represents a TCP or UDP service available on a device or virtual machine. 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 its parent. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)

View File

@@ -0,0 +1,29 @@
NetBox supports the definition of virtual machines arranged in clusters. A cluster can optionally have physical host devices associated with it.
# Clusters
A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned an organizational group.
Physical devices (from NetBox's DCIM component) may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
### Cluster Types
A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider.
### Cluster Groups
Cluster groups may be created for the purpose of organizing clusters.
---
# Virtual Machines
A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster.
Like devices, each VM can have interfaces created on it. These behave similarly to device interfaces, and can be assigned IP addresses, however given their virtual nature they cannot be connected to other interfaces. VMs can also be assigned layer four services. Unlike physical devices, VMs cannot be assigned console or power ports, or device bays.
The following resources can be defined for each VM:
* vCPU count
* Memory (MB)
* Disk space (GB)

View File

@@ -0,0 +1,53 @@
# Utility Views
Utility views are reusable views that handle common CRUD tasks, such as listing and updating objects. Some views operate on individual objects, whereas others (referred to as "bulk" views) operate on multiple objects at once.
## Individual Views
### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
### ObjectEditView
Updates an object identified by a primary key (PK) or slug. If no existing object is specified, a new object will be created.
### ObjectDeleteView
Deletes an object. The user is redirected to a confirmation page before the deletion is executed.
## Bulk Views
### BulkCreateView
Creates multiple objects at once based on a given pattern. Currently used only for IP addresses.
### BulkImportView
Accepts CSV-formatted data and creates a new object for each line. Creation is all-or-none.
### BulkEditView
Applies changes to multiple objects at once in a two-step operation. First, the list of PKs for selected objects is POSTed and an edit form is presented to the user. On submission of that form, the specified changes are made to all selected objects.
### BulkDeleteView
Deletes multiple objects. The user selects the objects to be deleted and confirms the deletion.
## Component Views
### ComponentCreateView
Create one or more component objects beloning to a parent object (e.g. interfaces attached to a device).
### ComponentEditView
A subclass of `ObjectEditView`: Updates an individual component object.
### ComponentDeleteView
A subclass of `ObjectDeleteView`: Deletes an individual component object.
### BulkComponentCreateView
Create a set of components objects for each of a selected set of parent objects. This view can be used e.g. to create multiple interfaces on multiple devices at once.

View File

@@ -6,6 +6,7 @@ NetBox is an open source web application designed to help manage and document co
* **Equipment racks** - Organized by group and site
* **Devices** - Types of devices and where they are installed
* **Connections** - Network, console, and power connections among devices
* **Virtualization** - Virtual machines and clusters
* **Data circuits** - Long-haul communications circuits and providers
* **Secrets** - Encrypted storage of sensitive credentials
@@ -46,7 +47,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP Service | nginx or Apache |
| WSGI Service | gunicorn or uWSGI |
| Application | Django/Python |
| Database | PostgreSQL |
| Database | PostgreSQL 9.4+ |
# Getting Started

View File

@@ -55,7 +55,7 @@ LDAP_IGNORE_CERT_ERRORS = True
## User Authentication
!!! info
When using Windows Server, `2012 AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
```python
from django_auth_ldap.config import LDAPSearch
@@ -78,6 +78,8 @@ AUTH_LDAP_USER_ATTR_MAP = {
```
# User Groups for Permissions
!!! Info
When using Microsoft Active Directory, Support for nested Groups can be activated by using `GroupOfNamesType()` instead of `NestedGroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`.
```python
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

View File

@@ -1,17 +1,24 @@
# Installation
This section of the documentation discusses installing and configuring the NetBox application.
!!! note
Python 3 is strongly encouraged for new installations. Support for Python 2 will be discontinued in the near future. This documentation includes a guide on [migrating from Python 2 to Python 3](migrating-to-python3).
**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 zlib1g-dev
# apt-get install -y python3 python3-dev python3-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# easy_install3 pip
```
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 zlib1g-dev
# apt-get install -y python2.7 python-dev python-setuptools build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# easy_install pip
```
**CentOS**
@@ -20,16 +27,16 @@ 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
# yum install -y gcc python34 python34-devel python34-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
# 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
# yum install -y gcc python2 python-devel python-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
# easy_install pip
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
@@ -97,6 +104,9 @@ Python 2:
# pip install -r requirements.txt
```
!!! note
If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip -V` or `pip3 -V`.
### NAPALM Automation
As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
@@ -158,13 +168,13 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
# Run Database Migrations
!!! warning
The examples on the rest of this page call the `python` executable, which will be Python2 on most systems. Replace this with `python3` if you're running NetBox on Python3.
The examples on the rest of this page call the `python3` executable. Replace this with `python2` or `python` if you're using Python 2.
Before NetBox can run, we need to install the database schema. This is done by running `python manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
```no-highlight
# cd /opt/netbox/netbox/
# python manage.py migrate
# python3 manage.py migrate
Operations to perform:
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
Running migrations:
@@ -182,7 +192,7 @@ If this step results in a PostgreSQL authentication error, ensure that the usern
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
```no-highlight
# python manage.py createsuperuser
# python3 manage.py createsuperuser
Username: admin
Email address: admin@example.com
Password:
@@ -193,7 +203,7 @@ Superuser created successfully.
# Collect Static Files
```no-highlight
# python manage.py collectstatic --no-input
# python3 manage.py collectstatic --no-input
You have requested to collect static files at the destination
location as specified in your settings:
@@ -214,7 +224,7 @@ NetBox ships with some initial data to help you get started: RIR definitions, co
This step is optional. It's perfectly fine to start using NetBox without using this initial data if you'd rather create everything from scratch.
```no-highlight
# python manage.py loaddata initial_data
# python3 manage.py loaddata initial_data
Installed 43 object(s) from 4 fixture(s)
```
@@ -223,7 +233,7 @@ Installed 43 object(s) from 4 fixture(s)
At this point, NetBox should be able to run. We can verify this by starting a development instance:
```no-highlight
# python manage.py runserver 0.0.0.0:8000 --insecure
# python3 manage.py runserver 0.0.0.0:8000 --insecure
Performing system checks...
System check identified no issues (0 silenced).

View File

@@ -1,12 +1,17 @@
NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).)
NetBox requires a PostgreSQL database to store data. This can be hosted locally or on a remote server. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/current/static/datatype-net-types.html).)
!!! note
The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 6.9. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The installation instructions provided here have been tested to work on Ubuntu 16.04 and CentOS 7.4. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
!!! warning
NetBox v2.2 and later requires PostgreSQL 9.4 or higher.
# Installation
**Ubuntu**
If a recent enough version of PostgreSQL is not available through your distribution's package manager, you'll need to install it from an official [PostgreSQL repository](https://wiki.postgresql.org/wiki/Apt).
```no-highlight
# apt-get update
# apt-get install -y postgresql libpq-dev
@@ -14,22 +19,26 @@ NetBox requires a PostgreSQL database to store data. (Please note that MySQL is
**CentOS**
CentOS 7.4 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6.
```no-highlight
# yum install -y postgresql postgresql-server postgresql-devel
# postgresql-setup initdb
# yum install https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm
# yum install postgresql96 postgresql96-server postgresql96-devel
# /usr/pgsql-9.6/bin/postgresql96-setup initdb
```
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example:
CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/9.6/data/pg_hba.conf`. For example:
```no-highlight
host all all 127.0.0.1/32 md5
host all all ::1/128 md5
```
Then, start the service:
Then, start the service and enable it to run at boot:
```no-highlight
# systemctl start postgresql
# systemctl start postgresql-9.6
# systemctl enable postgresql-9.6
```
# Database Creation
@@ -41,7 +50,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
```no-highlight
# sudo -u postgres psql
psql (9.3.13)
psql (9.4.5)
Type "help" for help.
postgres=# CREATE DATABASE netbox;
@@ -53,10 +62,10 @@ GRANT
postgres=# \q
```
You can verify that authentication works issuing the following command and providing the configured password:
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
```no-highlight
# psql -U netbox -h localhost -W
# psql -U netbox -W -h localhost netbox
```
If successful, you will enter a `postgres` prompt. Type `\q` to exit.
If successful, you will enter a `netbox` prompt. Type `\q` to exit.

View File

@@ -51,7 +51,7 @@ Restart the nginx service to use the new configuration.
# service nginx restart
```
To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-14-04).
To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04).
## Option B: Apache
@@ -96,7 +96,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
# service apache2 restart
```
To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-14-04).
To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-16-04).
# gunicorn Installation

View File

@@ -0,0 +1,129 @@
# NetBox Reports
A NetBox report is a mechanism for validating the integrity of data within NetBox. Running a report allows the user to verify that the objects defined within NetBox meet certain arbitrary conditions. For example, you can write reports to check that:
* All top-of-rack switches have a console connection
* Every router has a loopback interface with an IP address assigned
* Each interface description conforms to a standard format
* Every site has a minimum set of VLANs defined
* All IP addresses have a parent prefix
...and so on. Reports are completely customizable, so there's practically no limit to what you can test for.
## Writing Reports
Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings/#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
!!! warning
The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file.
For example, we can create a module named `devices.py` to hold all of our reports which pertain to devices in NetBox. Within that module, we might define several reports. Each report is defined as a Python class inheriting from `extras.reports.Report`.
```
from extras.reports import Report
class DeviceConnectionsReport(Report):
description = "Validate the minimum physical connections for each device"
class DeviceIPsReport(Report):
description = "Check that every device has a primary IP address assigned"
```
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
```
from dcim.constants import CONNECTION_STATUS_PLANNED, STATUS_ACTIVE
from dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report
class DeviceConnectionsReport(Report):
description = "Validate the minimum physical connections for each device"
def test_console_connection(self):
# Check that every console port for every active device has a connection defined.
for console_port in ConsolePort.objects.select_related('device').filter(device__status=STATUS_ACTIVE):
if console_port.cs_port is None:
self.log_failure(
console_port.device,
"No console connection defined for {}".format(console_port.name)
)
elif console_port.connection_status == CONNECTION_STATUS_PLANNED:
self.log_warning(
console_port.device,
"Console connection for {} marked as planned".format(console_port.name)
)
else:
self.log_success(console_port.device)
def test_power_connections(self):
# Check that every active device has at least two connected power supplies.
for device in Device.objects.filter(status=STATUS_ACTIVE):
connected_ports = 0
for power_port in PowerPort.objects.filter(device=device):
if power_port.power_outlet is not None:
connected_ports += 1
if power_port.connection_status == CONNECTION_STATUS_PLANNED:
self.log_warning(
device,
"Power connection for {} marked as planned".format(power_port.name)
)
if connected_ports < 2:
self.log_failure(
device,
"{} connected power supplies found (2 needed)".format(connected_ports)
)
else:
self.log_success(device)
```
As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed.
!!! warning
Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data.
The following methods are available to log results within a report:
* log(message)
* log_success(object, message=None)
* log_info(object, message)
* log_warning(object, message)
* log_failure(object, message)
The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status.
Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report.
## Running Reports
### Via the Web UI
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
Once a report has been run, its associated results will be included in the report view.
### Via the API
To run a report via the API, simply issue a POST request to its `run` endpoint. Reports are identified by their module and class name.
```
POST /api/extras/reports/<module>.<name>/run/
```
Our example report above would be called as:
```
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
```
### Via the CLI
Reports can be run on the CLI by invoking the management command:
```
python3 manage.py runreport <module>
```
One or more report modules may be specified.

View File

@@ -18,14 +18,18 @@ pages:
- 'IPAM': 'data-model/ipam.md'
- 'Secrets': 'data-model/secrets.md'
- 'Tenancy': 'data-model/tenancy.md'
- 'Virtualization': 'data-model/virtualization.md'
- 'Extras': 'data-model/extras.md'
- 'API':
- 'Overview': 'api/overview.md'
- 'Authentication': 'api/authentication.md'
- 'Working with Secrets': 'api/working-with-secrets.md'
- 'Examples': 'api/examples.md'
- 'Shell':
- 'Introduction': 'shell/intro.md'
- 'Miscellaneous':
- 'Reports': 'miscellaneous/reports.md'
- 'Shell': 'miscellaneous/shell.md'
- 'Development':
- 'Utility Views': 'development/utility-views.md'
markdown_extensions:
- admonition:

View File

@@ -16,6 +16,9 @@ class CircuitsRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = CircuitsRootView
# Field choices
router.register(r'_choices', views.CircuitsFieldChoicesViewSet, base_name='field-choice')
# Providers
router.register(r'providers', views.ProviderViewSet)

View File

@@ -11,10 +11,20 @@ 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 utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
#
# Field choices
#
class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(CircuitTermination, ['term_side']),
)
#
# Providers
#

View File

@@ -85,6 +85,17 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug']
class CircuitTypeCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = CircuitType
fields = ['name', 'slug']
help_texts = {
'name': 'Name of circuit type',
}
#
# Circuits
#
@@ -170,6 +181,7 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
to_field_name='slug'
)
commit_rate = forms.IntegerField(required=False, min_value=0, label='Commit rate (Kbps)')
#

View File

@@ -13,22 +13,6 @@ from utilities.models import CreatedUpdatedModel
from .constants import *
def humanize_speed(speed):
"""
Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
"""
if speed >= 1000000000 and speed % 1000000000 == 0:
return '{} Tbps'.format(speed / 1000000000)
elif speed >= 1000000 and speed % 1000000 == 0:
return '{} Gbps'.format(speed / 1000000)
elif speed >= 1000 and speed % 1000 == 0:
return '{} Mbps'.format(speed / 1000)
elif speed >= 1000:
return '{} Mbps'.format(float(speed) / 1000)
else:
return '{} Kbps'.format(speed)
@python_2_unicode_compatible
class Provider(CreatedUpdatedModel, CustomFieldModel):
"""
@@ -139,10 +123,6 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
def termination_z(self):
return self._get_termination('Z')
def commit_rate_human(self):
return '' if not self.commit_rate else humanize_speed(self.commit_rate)
commit_rate_human.admin_order_field = 'commit_rate'
@python_2_unicode_compatible
class CircuitTermination(models.Model):
@@ -173,11 +153,3 @@ class CircuitTermination(models.Model):
return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side)
except CircuitTermination.DoesNotExist:
return None
def port_speed_human(self):
return humanize_speed(self.port_speed)
port_speed_human.admin_order_field = 'port_speed'
def upstream_speed_human(self):
return '' if not self.upstream_speed else humanize_speed(self.upstream_speed)
upstream_speed_human.admin_order_field = 'upstream_speed'

View File

@@ -21,6 +21,7 @@ urlpatterns = [
# Circuit types
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),

View File

@@ -52,7 +52,7 @@ class ProviderView(View):
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_provider'
model = Provider
form_class = forms.ProviderForm
model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list'
@@ -104,7 +104,7 @@ class CircuitTypeListView(ObjectListView):
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittype'
model = CircuitType
form_class = forms.CircuitTypeForm
model_form = forms.CircuitTypeForm
def get_return_url(self, request, obj):
return reverse('circuits:circuittype_list')
@@ -114,6 +114,13 @@ class CircuitTypeEditView(CircuitTypeCreateView):
permission_required = 'circuits.change_circuittype'
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuittype'
model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
cls = CircuitType
@@ -160,7 +167,7 @@ class CircuitView(View):
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuit'
model = Circuit
form_class = forms.CircuitForm
model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list'
@@ -253,7 +260,7 @@ def circuit_terminations_swap(request, pk):
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittermination'
model = CircuitTermination
form_class = forms.CircuitTerminationForm
model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
def alter_obj(self, obj, request, url_args, url_kwargs):

View File

@@ -16,6 +16,7 @@ from dcim.models import (
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.models import Cluster
#
@@ -141,8 +142,8 @@ class RackSerializer(CustomFieldModelSerializer):
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units', 'comments', 'custom_fields',
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments', 'custom_fields',
]
@@ -159,8 +160,8 @@ class WritableRackSerializer(CustomFieldModelSerializer):
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'comments', 'custom_fields',
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', '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.
@@ -403,7 +404,7 @@ class DeviceRoleSerializer(ValidatedModelSerializer):
class Meta:
model = DeviceRole
fields = ['id', 'name', 'slug', 'color']
fields = ['id', 'name', 'slug', 'color', 'vm_role']
class NestedDeviceRoleSerializer(serializers.ModelSerializer):
@@ -446,6 +447,15 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer):
fields = ['id', 'url', 'family', 'address']
# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency
class NestedClusterSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta:
model = Cluster
fields = ['id', 'url', 'name']
class DeviceSerializer(CustomFieldModelSerializer):
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
@@ -459,13 +469,14 @@ class DeviceSerializer(CustomFieldModelSerializer):
primary_ip4 = DeviceIPAddressSerializer()
primary_ip6 = DeviceIPAddressSerializer()
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer()
class Meta:
model = Device
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',
'cluster', 'comments', 'custom_fields',
]
def get_parent_device(self, obj):
@@ -485,7 +496,7 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
model = Device
fields = [
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields',
]
validators = []

View File

@@ -16,6 +16,9 @@ class DCIMRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = DCIMRootView
# Field choices
router.register(r'_choices', views.DCIMFieldChoicesViewSet, base_name='field-choice')
# Sites
router.register(r'regions', views.RegionViewSet)
router.register(r'sites', views.SiteViewSet)

View File

@@ -20,11 +20,27 @@ from dcim import filters
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 IsAuthenticatedOrLoginNotRequired, ServiceUnavailable, WritableSerializerMixin
from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin
from .exceptions import MissingFilterException
from . import serializers
#
# Field choices
#
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(Device, ['face', 'status']),
(ConsolePort, ['connection_status']),
(Interface, ['form_factor']),
(InterfaceConnection, ['connection_status']),
(InterfaceTemplate, ['form_factor']),
(PowerPort, ['connection_status']),
(Rack, ['type', 'width']),
)
#
# Regions
#

View File

@@ -58,6 +58,7 @@ IFACE_FF_1GE_FIXED = 1000
IFACE_FF_1GE_GBIC = 1050
IFACE_FF_1GE_SFP = 1100
IFACE_FF_10GE_FIXED = 1150
IFACE_FF_10GE_CX4 = 1170
IFACE_FF_10GE_SFP_PLUS = 1200
IFACE_FF_10GE_XFP = 1300
IFACE_FF_10GE_XENPAK = 1310
@@ -65,6 +66,9 @@ IFACE_FF_10GE_X2 = 1320
IFACE_FF_25GE_SFP28 = 1350
IFACE_FF_40GE_QSFP_PLUS = 1400
IFACE_FF_100GE_CFP = 1500
IFACE_FF_100GE_CFP2 = 1510
IFACE_FF_100GE_CFP4 = 1520
IFACE_FF_100GE_CPAK = 1550
IFACE_FF_100GE_QSFP28 = 1600
# Wireless
IFACE_FF_80211A = 2600
@@ -98,7 +102,7 @@ IFACE_FF_CHOICES = [
[
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
]
],
],
[
'Ethernet (fixed)',
@@ -106,6 +110,7 @@ IFACE_FF_CHOICES = [
[IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
[IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
[IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
[IFACE_FF_10GE_CX4, '10GBASE-CX4 (10GE)'],
]
],
[
@@ -120,6 +125,9 @@ IFACE_FF_CHOICES = [
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
[IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
[IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
]
],

View File

@@ -10,6 +10,7 @@ from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
from virtualization.models import Cluster
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
@@ -158,7 +159,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
class Meta:
model = Rack
fields = ['type', 'width', 'u_height', 'desc_units']
fields = ['serial', 'type', 'width', 'u_height', 'desc_units']
def search(self, queryset, name, value):
if not value.strip():
@@ -166,6 +167,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
return queryset.filter(
Q(name__icontains=value) |
Q(facility_id__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(comments__icontains=value)
)
@@ -273,6 +275,7 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
class DeviceTypeComponentFilterSet(django_filters.FilterSet):
devicetype_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
name='device_type_id',
label='Device type (ID)',
)
@@ -407,6 +410,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=Rack.objects.all(),
label='Rack (ID)',
)
cluster_id = NullableModelMultipleChoiceFilter(
queryset=Cluster.objects.all(),
label='VM cluster (ID)',
)
model = django_filters.ModelMultipleChoiceFilter(
name='device_type__slug',
queryset=DeviceType.objects.all(),

View File

@@ -13,10 +13,11 @@ from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField,
FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
FilterTreeNodeMultipleChoiceField,
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
SlugField, FilterTreeNodeMultipleChoiceField,
)
from virtualization.models import Cluster
from .formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, ConsolePort,
@@ -24,16 +25,10 @@ from .models import (
IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES,
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN,
Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
)
FORM_STATUS_CHOICES = [
['', '---------'],
]
FORM_STATUS_CHOICES += STATUS_CHOICES
DEVICE_BY_PK_RE = '{\d+\}'
@@ -49,15 +44,6 @@ def get_device_by_name_or_pk(name):
return device
class DeviceComponentForm(BootstrapMixin, forms.Form):
"""
Allow inclusion of the parent device as context for limiting field choices.
"""
def __init__(self, device, *args, **kwargs):
self.device = device
super(DeviceComponentForm, self).__init__(*args, **kwargs)
#
# Regions
#
@@ -70,6 +56,28 @@ class RegionForm(BootstrapMixin, forms.ModelForm):
fields = ['parent', 'name', 'slug']
class RegionCSVForm(forms.ModelForm):
parent = forms.ModelChoiceField(
queryset=Region.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent region',
error_messages={
'invalid_choice': 'Region not found.',
}
)
class Meta:
model = Region
fields = [
'name', 'slug', 'parent',
]
help_texts = {
'name': 'Region name',
'slug': 'URL-friendly slug',
}
#
# Sites
#
@@ -168,6 +176,27 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
fields = ['site', 'name', 'slug']
class RackGroupCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
)
class Meta:
model = RackGroup
fields = [
'site', 'name', 'slug',
]
help_texts = {
'name': 'Name of rack group',
'slug': 'URL-friendly slug',
}
class RackGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug')
@@ -184,6 +213,18 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'color']
class RackRoleCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = RackRole
fields = ['name', 'slug', 'color']
help_texts = {
'name': 'Name of rack role',
'color': 'RGB color in hexadecimal (e.g. 00ff00)'
}
#
# Racks
#
@@ -204,8 +245,8 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class Meta:
model = Rack
fields = [
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'type', 'width', 'u_height',
'desc_units', 'comments',
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'role', 'serial', 'type', 'width',
'u_height', 'desc_units', 'comments',
]
help_texts = {
'site': "The site at which the rack exists",
@@ -265,7 +306,8 @@ class RackCSVForm(forms.ModelForm):
class Meta:
model = Rack
fields = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
'desc_units',
]
help_texts = {
'name': 'Rack name',
@@ -293,6 +335,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
role = forms.ModelChoiceField(queryset=RackRole.objects.all(), required=False)
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type')
width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width')
u_height = forms.IntegerField(required=False, label='Height (U)')
@@ -300,7 +343,7 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
comments = CommentField(widget=SmallTextarea)
class Meta:
nullable_fields = ['group', 'tenant', 'role', 'comments']
nullable_fields = ['group', 'tenant', 'role', 'serial', 'comments']
class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -380,6 +423,18 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug']
class ManufacturerCSVForm(forms.ModelForm):
class Meta:
model = Manufacturer
fields = [
'name', 'slug'
]
help_texts = {
'name': 'Manufacturer name',
'slug': 'URL-friendly slug',
}
#
# Device types
#
@@ -396,6 +451,37 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
}
class DeviceTypeCSVForm(forms.ModelForm):
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
required=True,
to_field_name='name',
help_text='Manufacturer name',
error_messages={
'invalid_choice': 'Manufacturer not found.',
}
)
subdevice_role = CSVChoiceField(
choices=SUBDEVICE_ROLE_CHOICES,
required=False,
help_text='Parent/child status'
)
interface_ordering = CSVChoiceField(
choices=IFACE_ORDERING_CHOICES,
required=False,
help_text='Interface ordering'
)
class Meta:
model = DeviceType
fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering', 'comments']
help_texts = {
'model': 'Model name',
'slug': 'URL-friendly slug',
}
class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput)
manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
@@ -452,7 +538,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class ConsolePortTemplateCreateForm(DeviceComponentForm):
class ConsolePortTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -466,7 +552,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class ConsoleServerPortTemplateCreateForm(DeviceComponentForm):
class ConsoleServerPortTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -480,7 +566,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class PowerPortTemplateCreateForm(DeviceComponentForm):
class PowerPortTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -494,7 +580,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
}
class PowerOutletTemplateCreateForm(DeviceComponentForm):
class PowerOutletTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -508,7 +594,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
}
class InterfaceTemplateCreateForm(DeviceComponentForm):
class InterfaceTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
@@ -533,7 +619,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
}
class DeviceBayTemplateCreateForm(DeviceComponentForm):
class DeviceBayTemplateCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -546,7 +632,19 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = DeviceRole
fields = ['name', 'slug', 'color']
fields = ['name', 'slug', 'color', 'vm_role']
class DeviceRoleCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = DeviceRole
fields = ['name', 'slug', 'color', 'vm_role']
help_texts = {
'name': 'Name of device role',
'color': 'RGB color in hexadecimal (e.g. 00ff00)'
}
#
@@ -561,6 +659,17 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'napalm_driver', 'rpc_client']
class PlatformCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = Platform
fields = ['name', 'slug', 'napalm_driver']
help_texts = {
'name': 'Platform name',
}
#
# Devices
#
@@ -642,13 +751,28 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
# Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]:
ip_choices = []
interface_ips = IPAddress.objects.filter(family=family, interface__device=self.instance)
ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
nat_ips = IPAddress.objects.filter(family=family, nat_inside__interface__device=self.instance)\
.select_related('nat_inside__interface')
ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips]
self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices
ip_choices = [(None, '---------')]
# Collect interface IPs
interface_ips = IPAddress.objects.select_related('interface').filter(
family=family, interface__device=self.instance
)
if interface_ips:
ip_choices.append(
('Interface IPs', [
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
])
)
# Collect NAT IPs
nat_ips = IPAddress.objects.select_related('nat_inside').filter(
family=family, nat_inside__interface__device=self.instance
)
if nat_ips:
ip_choices.append(
('NAT IPs', [
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
])
)
self.fields['primary_ip{}'.format(family)].choices = ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another.
@@ -777,11 +901,20 @@ class DeviceCSVForm(BaseDeviceCSVForm):
required=False,
help_text='Mounted rack face'
)
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
help_text='Virtualization cluster',
error_messages={
'invalid_choice': 'Invalid cluster name.',
}
)
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face',
'site', 'rack_group', 'rack_name', 'position', 'face', 'cluster',
]
def clean(self):
@@ -817,11 +950,19 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
device_bay_name = forms.CharField(
help_text='Name of device bay',
)
cluster = forms.ModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
help_text='Virtualization cluster',
error_messages={
'invalid_choice': 'Invalid cluster name.',
}
)
class Meta(BaseDeviceCSVForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay_name',
'parent', 'device_bay_name', 'cluster',
]
def clean(self):
@@ -848,11 +989,11 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
status = forms.ChoiceField(choices=FORM_STATUS_CHOICES, required=False, initial='', label='Status')
status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
class Meta:
nullable_fields = ['tenant', 'platform']
nullable_fields = ['tenant', 'platform', 'serial']
def device_status_choices():
@@ -912,11 +1053,12 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(label='Name')
class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm):
class Meta:
model = Interface
fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description']
class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False, initial=True)
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mgmt_only = forms.BooleanField(required=False, label='OOB Management')
description = forms.CharField(max_length=100, required=False)
#
@@ -933,7 +1075,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
}
class ConsolePortCreateForm(DeviceComponentForm):
class ConsolePortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -1102,7 +1244,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
}
class ConsoleServerPortCreateForm(DeviceComponentForm):
class ConsoleServerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -1194,7 +1336,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
}
class PowerPortCreateForm(DeviceComponentForm):
class PowerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -1363,7 +1505,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
}
class PowerOutletCreateForm(DeviceComponentForm):
class PowerOutletCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
@@ -1468,7 +1610,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
)
class InterfaceCreateForm(DeviceComponentForm):
class InterfaceCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
enabled = forms.BooleanField(required=False)
@@ -1487,9 +1629,9 @@ class InterfaceCreateForm(DeviceComponentForm):
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
# Limit LAG choices to interfaces belonging to this device
if self.device is not None:
if self.parent is not None:
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
device=self.device, form_factor=IFACE_FF_LAG
device=self.parent, form_factor=IFACE_FF_LAG
)
else:
self.fields['lag'].queryset = Interface.objects.none()
@@ -1715,7 +1857,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
}
class DeviceBayCreateForm(DeviceComponentForm):
class DeviceBayCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')

View File

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

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-08-29 21:26
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0042_interface_ff_10ge_cx4'),
]
operations = [
migrations.AlterField(
model_name='consoleport',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='consoleporttemplate',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='consoleserverport',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='consoleserverporttemplate',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='devicebaytemplate',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='interface',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='interfacetemplate',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='poweroutlet',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='powerport',
name='name',
field=models.CharField(max_length=50),
),
migrations.AlterField(
model_name='powerporttemplate',
name='name',
field=models.CharField(max_length=50),
),
]

View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-08-31 14:15
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0001_virtualization'),
('dcim', '0043_device_component_name_lengths'),
]
operations = [
migrations.AddField(
model_name='device',
name='cluster',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'),
),
migrations.AddField(
model_name='interface',
name='virtual_machine',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'),
),
migrations.AlterField(
model_name='interface',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-09-29 16:09
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0044_virtualization'),
]
operations = [
migrations.AddField(
model_name='devicerole',
name='vm_role',
field=models.BooleanField(default=True, help_text='Virtual machines may be assigned to this role', verbose_name='VM Role'),
),
]

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-10-09 17:43
from __future__ import unicode_literals
from django.db import migrations
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0045_devicerole_vm_role'),
]
operations = [
migrations.AlterField(
model_name='rack',
name='facility_id',
field=utilities.fields.NullableCharField(blank=True, max_length=50, null=True, verbose_name='Facility ID'),
),
]

View File

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

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-10-09 18:50
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0047_more_100ge_form_factors'),
]
operations = [
migrations.AddField(
model_name='rack',
name='serial',
field=models.CharField(blank=True, max_length=50, verbose_name='Serial number'),
),
]

View File

@@ -26,6 +26,7 @@ from utilities.models import CreatedUpdatedModel
from utilities.utils import csv_format
from .constants import *
from .fields import ASNField, MACAddressField
from .querysets import InterfaceQuerySet
#
@@ -43,6 +44,10 @@ class Region(MPTTModel):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
csv_headers = [
'name', 'slug', 'parent',
]
class MPTTMeta:
order_insertion_by = ['name']
@@ -52,6 +57,13 @@ class Region(MPTTModel):
def get_absolute_url(self):
return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
def to_csv(self):
return csv_format([
self.name,
self.slug,
self.parent.name if self.parent else None,
])
#
# Sites
@@ -148,6 +160,10 @@ class RackGroup(models.Model):
slug = models.SlugField()
site = models.ForeignKey('Site', related_name='rack_groups', on_delete=models.CASCADE)
csv_headers = [
'site', 'name', 'slug',
]
class Meta:
ordering = ['site', 'name']
unique_together = [
@@ -161,6 +177,13 @@ class RackGroup(models.Model):
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk)
def to_csv(self):
return csv_format([
self.site,
self.name,
self.slug,
])
@python_2_unicode_compatible
class RackRole(models.Model):
@@ -194,11 +217,12 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
Each Rack is assigned to a Site and (optionally) a RackGroup.
"""
name = models.CharField(max_length=50)
facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID')
facility_id = NullableCharField(max_length=50, blank=True, null=True, verbose_name='Facility ID')
site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT)
group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL)
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT)
role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type')
width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width',
help_text='Rail-to-rail width')
@@ -213,7 +237,8 @@ class Rack(CreatedUpdatedModel, CustomFieldModel):
objects = RackManager()
csv_headers = [
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'desc_units',
'site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'serial', 'width', 'u_height',
'desc_units',
]
class Meta:
@@ -443,6 +468,10 @@ class Manufacturer(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
csv_headers = [
'name', 'slug',
]
class Meta:
ordering = ['name']
@@ -452,6 +481,12 @@ class Manufacturer(models.Model):
def get_absolute_url(self):
return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
def to_csv(self):
return csv_format([
self.name,
self.slug,
])
@python_2_unicode_compatible
class DeviceType(models.Model, CustomFieldModel):
@@ -491,6 +526,11 @@ class DeviceType(models.Model, CustomFieldModel):
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server',
'is_pdu', 'is_network_device', 'subdevice_role', 'interface_ordering',
]
class Meta:
ordering = ['manufacturer', 'model']
unique_together = [
@@ -510,6 +550,21 @@ class DeviceType(models.Model, CustomFieldModel):
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
def to_csv(self):
return csv_format([
self.manufacturer.name,
self.model,
self.slug,
self.part_number,
self.u_height,
self.is_full_depth,
self.is_console_server,
self.is_pdu,
self.is_network_device,
self.get_subdevice_role_display() if self.subdevice_role else None,
self.get_interface_ordering_display(),
])
def clean(self):
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
@@ -574,7 +629,7 @@ class ConsolePortTemplate(models.Model):
A template for a ConsolePort to be created for a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='console_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
class Meta:
ordering = ['device_type', 'name']
@@ -590,7 +645,7 @@ class ConsoleServerPortTemplate(models.Model):
A template for a ConsoleServerPort to be created for a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='cs_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
class Meta:
ordering = ['device_type', 'name']
@@ -606,7 +661,7 @@ class PowerPortTemplate(models.Model):
A template for a PowerPort to be created for a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='power_port_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
class Meta:
ordering = ['device_type', 'name']
@@ -622,7 +677,7 @@ class PowerOutletTemplate(models.Model):
A template for a PowerOutlet to be created for a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='power_outlet_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
class Meta:
ordering = ['device_type', 'name']
@@ -632,60 +687,13 @@ class PowerOutletTemplate(models.Model):
return self.name
class InterfaceQuerySet(models.QuerySet):
def order_naturally(self, method=IFACE_ORDERING_POSITION):
"""
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 six distinct components: leading text (type),
slot, subslot, position, channel, and virtual circuit:
{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:
name = 'GigabitEthernet'
slot = None
subslot = 0
position = 1
channel = None
vc = 0
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.
"""
sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = {
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'),
IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'),
}[method]
return self.extra(select={
'_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col),
'_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
}).order_by(*ordering)
def connectable(self):
"""
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
wireless).
"""
return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)
@python_2_unicode_compatible
class InterfaceTemplate(models.Model):
"""
A template for a physical data interface on a new Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=64)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
mgmt_only = models.BooleanField(default=False, verbose_name='Management only')
@@ -705,7 +713,7 @@ class DeviceBayTemplate(models.Model):
A template for a DeviceBay to be created for a new parent Device.
"""
device_type = models.ForeignKey('DeviceType', related_name='device_bay_templates', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
class Meta:
ordering = ['device_type', 'name']
@@ -723,11 +731,17 @@ class DeviceBayTemplate(models.Model):
class DeviceRole(models.Model):
"""
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
color to be used when displaying rack elevations.
color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
virtual machines as well.
"""
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True)
color = ColorField()
vm_role = models.BooleanField(
default=True,
verbose_name="VM Role",
help_text="Virtual machines may be assigned to this role"
)
class Meta:
ordering = ['name']
@@ -808,6 +822,13 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
verbose_name='Primary IPv6'
)
cluster = models.ForeignKey(
to='virtualization.Cluster',
on_delete=models.SET_NULL,
related_name='devices',
blank=True,
null=True
)
comments = models.TextField(blank=True)
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
images = GenericRelation(ImageAttachment)
@@ -891,17 +912,35 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
pass
# Validate primary IPv4 address
if self.primary_ip4 and (self.primary_ip4.interface is None or self.primary_ip4.interface.device != self):
if self.primary_ip4 and (
self.primary_ip4.interface is None or
self.primary_ip4.interface.device != self
) and (
self.primary_ip4.nat_inside.interface is None or
self.primary_ip4.nat_inside.interface.device != self
):
raise ValidationError({
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
})
# Validate primary IPv6 address
if self.primary_ip6 and (self.primary_ip6.interface is None or self.primary_ip6.interface.device != self):
if self.primary_ip6 and (
self.primary_ip6.interface is None or
self.primary_ip6.interface.device != self
) and (
self.primary_ip6.nat_inside.interface is None or
self.primary_ip6.nat_inside.interface.device != self
):
raise ValidationError({
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
})
# A Device can only be assigned to a Cluster in the same Site (or no Site)
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
raise ValidationError({
'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
})
def save(self, *args, **kwargs):
is_new = not bool(self.pk)
@@ -1012,7 +1051,7 @@ class ConsolePort(models.Model):
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL,
verbose_name='Console server port', blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
@@ -1062,7 +1101,7 @@ class ConsoleServerPort(models.Model):
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
device = models.ForeignKey('Device', related_name='cs_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
objects = ConsoleServerPortManager()
@@ -1083,7 +1122,7 @@ class PowerPort(models.Model):
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
device = models.ForeignKey('Device', related_name='power_ports', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL,
blank=True, null=True)
connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED)
@@ -1127,7 +1166,7 @@ class PowerOutlet(models.Model):
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
device = models.ForeignKey('Device', related_name='power_outlets', on_delete=models.CASCADE)
name = models.CharField(max_length=30)
name = models.CharField(max_length=50)
objects = PowerOutletManager()
@@ -1145,19 +1184,32 @@ class PowerOutlet(models.Model):
@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.
A network interface within a Device or VirtualMachine. A physical 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)
device = models.ForeignKey(
to='Device',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
lag = models.ForeignKey(
'self',
models.SET_NULL,
to='self',
on_delete=models.SET_NULL,
related_name='member_interfaces',
null=True,
blank=True,
verbose_name='Parent LAG'
)
name = models.CharField(max_length=30)
name = models.CharField(max_length=64)
form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
enabled = models.BooleanField(default=True)
mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
@@ -1180,6 +1232,18 @@ class Interface(models.Model):
def clean(self):
# An Interface must belong to a Device *or* to a VirtualMachine
if self.device and self.virtual_machine:
raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
if not self.device and not self.virtual_machine:
raise ValidationError("An interface must belong to either a device or a virtual machine.")
# VM interfaces must be virtual
if self.virtual_machine and self.form_factor is not IFACE_FF_VIRTUAL:
raise ValidationError({
'form_factor': "Virtual machines can only have virtual interfaces."
})
# Virtual interfaces cannot be connected
if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected:
raise ValidationError({
@@ -1209,6 +1273,10 @@ class Interface(models.Model):
)
})
@property
def parent(self):
return self.device or self.virtual_machine
@property
def is_virtual(self):
return self.form_factor in VIRTUAL_IFACE_TYPES

72
netbox/dcim/querysets.py Normal file
View File

@@ -0,0 +1,72 @@
from __future__ import unicode_literals
from django.db.models import QuerySet
from django.db.models.expressions import RawSQL
from .constants import IFACE_ORDERING_NAME, IFACE_ORDERING_POSITION, NONCONNECTABLE_IFACE_TYPES
class InterfaceQuerySet(QuerySet):
def order_naturally(self, method=IFACE_ORDERING_POSITION):
"""
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 six distinct components: leading text (type),
slot, subslot, position, channel, and virtual circuit:
{type}{slot}/{subslot}/{position}/{subposition}:{channel}.{vc}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet1/2/3
would be parsed as follows:
name = 'GigabitEthernet'
slot = 1
subslot = 2
position = 3
subposition = 0
channel = None
vc = 0
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.
"""
sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = {
IFACE_ORDERING_POSITION: (
'_slot', '_subslot', '_position', '_subposition', '_channel', '_type', '_vc', '_id', 'name',
),
IFACE_ORDERING_NAME: (
'_type', '_slot', '_subslot', '_position', '_subposition', '_channel', '_vc', '_id', 'name',
),
}[method]
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9]+)')"
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)$') AS integer)"
SLOT_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9]+)([0-9]+)\/') AS integer)"
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/)([0-9]+)') AS integer), 0)"
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{2}}([0-9]+)') AS integer), 0)"
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)(?:[0-9]+\/){{3}}([0-9]+)') AS integer), 0)"
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)"
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)"
fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []),
'_id': RawSQL(ID_RE.format(sql_col), []),
'_slot': RawSQL(SLOT_RE.format(sql_col), []),
'_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
'_position': RawSQL(POSITION_RE.format(sql_col), []),
'_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
'_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
'_vc': RawSQL(VC_RE.format(sql_col), []),
}
return self.annotate(**fields).order_by(*ordering)
def connectable(self):
"""
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
wireless).
"""
return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)

View File

@@ -362,14 +362,14 @@ class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
device_count = tables.Column(verbose_name='Devices')
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
verbose_name='')
class Meta(BaseTable.Meta):
model = DeviceRole
fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions')
fields = ('pk', 'name', 'device_count', 'color', 'vm_role', 'slug', 'actions')
#
@@ -381,13 +381,12 @@ 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', 'rpc_client', 'actions')
fields = ('pk', 'name', 'device_count', 'slug', 'napalm_driver', 'actions')
#

View File

@@ -98,3 +98,112 @@ class RackTestCase(TestCase):
face=None,
)
self.assertTrue(pdu)
class InterfaceTestCase(TestCase):
def setUp(self):
self.site = Site.objects.create(
name='TestSite1',
slug='my-test-site'
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101',
site=self.site,
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
name='Acme',
slug='acme'
)
self.device_type = DeviceType.objects.create(
manufacturer=self.manufacturer,
model='FrameForwarder 2048',
slug='ff2048'
)
self.role = DeviceRole.objects.create(
name='Switch',
slug='switch',
)
def test_interface_order_natural(self):
device1 = Device.objects.create(
name='TestSwitch1',
device_type=self.device_type,
device_role=self.role,
site=self.site,
rack=self.rack,
position=10,
face=RACK_FACE_REAR,
)
interface1 = Interface.objects.create(
device=device1,
name='Ethernet1/3/1'
)
interface2 = Interface.objects.create(
device=device1,
name='Ethernet1/5/1'
)
interface3 = Interface.objects.create(
device=device1,
name='Ethernet1/4'
)
interface4 = Interface.objects.create(
device=device1,
name='Ethernet1/3/2/4'
)
interface5 = Interface.objects.create(
device=device1,
name='Ethernet1/3/2/1'
)
interface6 = Interface.objects.create(
device=device1,
name='Loopback1'
)
self.assertEqual(
list(Interface.objects.all().order_naturally()),
[interface1, interface5, interface4, interface3, interface2, interface6]
)
def test_interface_order_natural_subinterfaces(self):
device1 = Device.objects.create(
name='TestSwitch1',
device_type=self.device_type,
device_role=self.role,
site=self.site,
rack=self.rack,
position=10,
face=RACK_FACE_REAR,
)
interface1 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/3'
)
interface2 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/2.2'
)
interface3 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/0.120'
)
interface4 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/0'
)
interface5 = Interface.objects.create(
device=device1,
name='GigabitEthernet0/0/1.117'
)
interface6 = Interface.objects.create(
device=device1,
name='GigabitEthernet0'
)
self.assertEqual(
list(Interface.objects.all().order_naturally()),
[interface4, interface3, interface5, interface2, interface1, interface6]
)

View File

@@ -15,6 +15,7 @@ urlpatterns = [
# Regions
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'),
url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'),
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'),
@@ -31,12 +32,14 @@ urlpatterns = [
# Rack groups
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
# Rack roles
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'),
url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
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'),
@@ -62,12 +65,14 @@ urlpatterns = [
# Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
# Device types
url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
url(r'^device-types/import/$', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'),
url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
@@ -102,12 +107,14 @@ urlpatterns = [
# Device roles
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
# Platforms
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'),
url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
@@ -126,7 +133,7 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'),
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports

View File

@@ -1,5 +1,4 @@
from __future__ import unicode_literals
from copy import deepcopy
import re
from natsort import natsorted
from operator import attrgetter
@@ -23,7 +22,8 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import (
@@ -34,113 +34,6 @@ from .models import (
)
EXPANSION_PATTERN = '\[(\d+-\d+)\]'
def xstr(s):
"""
Replace None with an empty string (for CSV export)
"""
return '' if s is None else str(s)
def expand_pattern(string):
"""
Expand a numeric pattern into a list of strings. Examples:
'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3']
'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7']
"""
lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1)
x, y = pattern.split('-')
for i in range(int(x), int(y) + 1):
if remnant:
for string in expand_pattern(remnant):
yield "{0}{1}{2}".format(lead, i, string)
else:
yield "{0}{1}".format(lead, i)
class ComponentCreateView(View):
parent_model = None
parent_field = None
model = None
form = None
model_form = None
def get(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(parent, initial=request.GET)
return render(request, 'dcim/device_component_add.html', {
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'return_url': parent.get_absolute_url(),
})
def post(self, request, pk):
parent = get_object_or_404(self.parent_model, pk=pk)
form = self.form(parent, request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for name in form.cleaned_data['name_pattern']:
component_data = {
self.parent_field: parent.pk,
'name': name,
}
# Replace objects with their primary key to keep component_form.clean() happy
for k, v in data.items():
if hasattr(v, 'pk'):
component_data[k] = v.pk
else:
component_data[k] = v
component_form = self.model_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
# Assign errors on the child form's name field to name_pattern on the parent form
if field == 'name':
field = 'name_pattern'
for e in errors:
form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
if not form.errors:
self.model.objects.bulk_create(new_components)
messages.success(request, "Added {} {} to {}.".format(
len(new_components), self.model._meta.verbose_name_plural, parent
))
if '_addanother' in request.POST:
return redirect(request.path)
else:
return redirect(parent.get_absolute_url())
return render(request, 'dcim/device_component_add.html', {
'parent': parent,
'component_type': self.model._meta.verbose_name,
'form': form,
'return_url': parent.get_absolute_url(),
})
class ComponentEditView(ObjectEditView):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
class ComponentDeleteView(ObjectDeleteView):
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
class BulkDisconnectView(View):
"""
An extendable view for disconnection console/power/interface components in bulk.
@@ -192,7 +85,7 @@ class RegionListView(ObjectListView):
class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_region'
model = Region
form_class = forms.RegionForm
model_form = forms.RegionForm
def get_return_url(self, request, obj):
return reverse('dcim:region_list')
@@ -202,6 +95,13 @@ class RegionEditView(RegionCreateView):
permission_required = 'dcim.change_region'
class RegionBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_region'
model_form = forms.RegionCSVForm
table = tables.RegionTable
default_return_url = 'dcim:region_list'
class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_region'
cls = Region
@@ -250,7 +150,7 @@ class SiteView(View):
class SiteCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_site'
model = Site
form_class = forms.SiteForm
model_form = forms.SiteForm
template_name = 'dcim/site_edit.html'
default_return_url = 'dcim:site_list'
@@ -297,7 +197,7 @@ class RackGroupListView(ObjectListView):
class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackgroup'
model = RackGroup
form_class = forms.RackGroupForm
model_form = forms.RackGroupForm
def get_return_url(self, request, obj):
return reverse('dcim:rackgroup_list')
@@ -307,6 +207,13 @@ class RackGroupEditView(RackGroupCreateView):
permission_required = 'dcim.change_rackgroup'
class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rackgroup'
model_form = forms.RackGroupCSVForm
table = tables.RackGroupTable
default_return_url = 'dcim:rackgroup_list'
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackgroup'
cls = RackGroup
@@ -329,7 +236,7 @@ class RackRoleListView(ObjectListView):
class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackrole'
model = RackRole
form_class = forms.RackRoleForm
model_form = forms.RackRoleForm
def get_return_url(self, request, obj):
return reverse('dcim:rackrole_list')
@@ -339,6 +246,13 @@ class RackRoleEditView(RackRoleCreateView):
permission_required = 'dcim.change_rackrole'
class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rackrole'
model_form = forms.RackRoleCSVForm
table = tables.RackRoleTable
default_return_url = 'dcim:rackrole_list'
class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackrole'
cls = RackRole
@@ -432,7 +346,7 @@ class RackView(View):
class RackCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rack'
model = Rack
form_class = forms.RackForm
model_form = forms.RackForm
template_name = 'dcim/rack_edit.html'
default_return_url = 'dcim:rack_list'
@@ -488,7 +402,7 @@ class RackReservationListView(ObjectListView):
class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_rackreservation'
model = RackReservation
form_class = forms.RackReservationForm
model_form = forms.RackReservationForm
def alter_obj(self, obj, request, args, kwargs):
if not obj.pk:
@@ -532,7 +446,7 @@ class ManufacturerListView(ObjectListView):
class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_manufacturer'
model = Manufacturer
form_class = forms.ManufacturerForm
model_form = forms.ManufacturerForm
def get_return_url(self, request, obj):
return reverse('dcim:manufacturer_list')
@@ -542,6 +456,13 @@ class ManufacturerEditView(ManufacturerCreateView):
permission_required = 'dcim.change_manufacturer'
class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_manufacturer'
model_form = forms.ManufacturerCSVForm
table = tables.ManufacturerTable
default_return_url = 'dcim:manufacturer_list'
class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_manufacturer'
cls = Manufacturer
@@ -617,7 +538,7 @@ class DeviceTypeView(View):
class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_devicetype'
model = DeviceType
form_class = forms.DeviceTypeForm
model_form = forms.DeviceTypeForm
template_name = 'dcim/devicetype_edit.html'
default_return_url = 'dcim:devicetype_list'
@@ -632,6 +553,13 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_devicetype'
model_form = forms.DeviceTypeCSVForm
table = tables.DeviceTypeTable
default_return_url = 'dcim:devicetype_list'
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_devicetype'
cls = DeviceType
@@ -662,6 +590,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
model = ConsolePortTemplate
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
template_name = 'dcim/device_component_add.html'
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -680,6 +609,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea
model = ConsoleServerPortTemplate
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
template_name = 'dcim/device_component_add.html'
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -696,6 +626,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
model = PowerPortTemplate
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
template_name = 'dcim/device_component_add.html'
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -712,6 +643,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
model = PowerOutletTemplate
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
template_name = 'dcim/device_component_add.html'
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -728,6 +660,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
model = InterfaceTemplate
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
template_name = 'dcim/device_component_add.html'
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -752,6 +685,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
model = DeviceBayTemplate
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
template_name = 'dcim/device_component_add.html'
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -774,7 +708,7 @@ class DeviceRoleListView(ObjectListView):
class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_devicerole'
model = DeviceRole
form_class = forms.DeviceRoleForm
model_form = forms.DeviceRoleForm
def get_return_url(self, request, obj):
return reverse('dcim:devicerole_list')
@@ -784,6 +718,13 @@ class DeviceRoleEditView(DeviceRoleCreateView):
permission_required = 'dcim.change_devicerole'
class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_devicerole'
model_form = forms.DeviceRoleCSVForm
table = tables.DeviceRoleTable
default_return_url = 'dcim:devicerole_list'
class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicerole'
cls = DeviceRole
@@ -805,7 +746,7 @@ class PlatformListView(ObjectListView):
class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_platform'
model = Platform
form_class = forms.PlatformForm
model_form = forms.PlatformForm
def get_return_url(self, request, obj):
return reverse('dcim:platform_list')
@@ -815,6 +756,13 @@ class PlatformEditView(PlatformCreateView):
permission_required = 'dcim.change_platform'
class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_platform'
model_form = forms.PlatformCSVForm
table = tables.PlatformTable
default_return_url = 'dcim:platform_list'
class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_platform'
cls = Platform
@@ -963,7 +911,7 @@ class DeviceConfigView(PermissionRequiredMixin, View):
class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_device'
model = Device
form_class = forms.DeviceForm
model_form = forms.DeviceForm
template_name = 'dcim/device_edit.html'
default_return_url = 'dcim:device_list'
@@ -1035,6 +983,7 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
model = ConsolePort
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
template_name = 'dcim/device_component_add.html'
@permission_required('dcim.change_consoleport')
@@ -1116,12 +1065,14 @@ def consoleport_disconnect(request, pk):
class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_consoleport'
model = ConsolePort
form_class = forms.ConsolePortForm
parent_field = 'device'
model_form = forms.ConsolePortForm
class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_consoleport'
model = ConsolePort
parent_field = 'device'
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1149,6 +1100,7 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
model = ConsoleServerPort
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
template_name = 'dcim/device_component_add.html'
@permission_required('dcim.change_consoleserverport')
@@ -1233,12 +1185,14 @@ def consoleserverport_disconnect(request, pk):
class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_consoleserverport'
model = ConsoleServerPort
form_class = forms.ConsoleServerPortForm
parent_field = 'device'
model_form = forms.ConsoleServerPortForm
class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_consoleserverport'
model = ConsoleServerPort
parent_field = 'device'
class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1268,6 +1222,7 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
model = PowerPort
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
template_name = 'dcim/device_component_add.html'
@permission_required('dcim.change_powerport')
@@ -1349,12 +1304,14 @@ def powerport_disconnect(request, pk):
class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_powerport'
model = PowerPort
form_class = forms.PowerPortForm
parent_field = 'device'
model_form = forms.PowerPortForm
class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_powerport'
model = PowerPort
parent_field = 'device'
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1382,6 +1339,7 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
model = PowerOutlet
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
template_name = 'dcim/device_component_add.html'
@permission_required('dcim.change_poweroutlet')
@@ -1466,12 +1424,14 @@ def poweroutlet_disconnect(request, pk):
class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_poweroutlet'
model = PowerOutlet
form_class = forms.PowerOutletForm
parent_field = 'device'
model_form = forms.PowerOutletForm
class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_poweroutlet'
model = PowerOutlet
parent_field = 'device'
class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1503,17 +1463,20 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
model = Interface
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
template_name = 'dcim/device_component_add.html'
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_interface'
model = Interface
form_class = forms.InterfaceForm
parent_field = 'device'
model_form = forms.InterfaceForm
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_interface'
model = Interface
parent_field = 'device'
class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1554,17 +1517,20 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
model = DeviceBay
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
template_name = 'dcim/device_component_add.html'
class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_devicebay'
model = DeviceBay
form_class = forms.DeviceBayForm
parent_field = 'device'
model_form = forms.DeviceBayForm
class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_devicebay'
model = DeviceBay
parent_field = 'device'
@permission_required('dcim.change_devicebay')
@@ -1625,109 +1591,73 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
# Bulk device component creation
# Bulk Device component creation
#
class DeviceBulkAddComponentView(View):
"""
Add one or more components (e.g. interfaces) to a selected set of Devices.
"""
form = forms.DeviceBulkAddComponentForm
model = None
model_form = None
def get(self):
return redirect('dcim:device_list')
def post(self, request):
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all'):
pk_list = [obj.pk for obj in filters.DeviceFilter(request.GET, Device.objects.all())]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
if '_create' in request.POST:
form = self.form(request.POST)
if form.is_valid():
new_components = []
data = deepcopy(form.cleaned_data)
for device in data['pk']:
names = data['name_pattern']
for name in names:
component_data = {
'device': device.pk,
'name': name,
}
component_data.update(data)
component_form = self.model_form(component_data)
if component_form.is_valid():
new_components.append(component_form.save(commit=False))
else:
for field, errors in component_form.errors.as_data().items():
for e in errors:
form.add_error(field, '{} {}: {}'.format(device, name, ', '.join(e)))
if not form.errors:
self.model.objects.bulk_create(new_components)
messages.success(request, "Added {} {} to {} devices.".format(
len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk'])
))
return redirect('dcim:device_list')
else:
form = self.form(initial={'pk': pk_list})
selected_devices = Device.objects.filter(pk__in=pk_list)
if not selected_devices:
messages.warning(request, "No devices were selected.")
return redirect('dcim:device_list')
return render(request, 'dcim/device_bulk_add_component.html', {
'form': form,
'component_name': self.model._meta.verbose_name_plural,
'selected_devices': selected_devices,
'return_url': reverse('dcim:device_list'),
})
class DeviceBulkAddConsolePortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView):
permission_required = 'dcim.add_consoleport'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = ConsolePort
model_form = forms.ConsolePortForm
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView):
permission_required = 'dcim.add_consoleserverport'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = ConsoleServerPort
model_form = forms.ConsoleServerPortForm
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
class DeviceBulkAddPowerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView):
class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView):
permission_required = 'dcim.add_powerport'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = PowerPort
model_form = forms.PowerPortForm
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, DeviceBulkAddComponentView):
class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView):
permission_required = 'dcim.add_poweroutlet'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = PowerOutlet
model_form = forms.PowerOutletForm
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
class DeviceBulkAddInterfaceView(PermissionRequiredMixin, DeviceBulkAddComponentView):
class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddInterfaceForm
model = Interface
model_form = forms.InterfaceForm
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, DeviceBulkAddComponentView):
class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
permission_required = 'dcim.add_devicebay'
parent_model = Device
parent_field = 'device'
form = forms.DeviceBulkAddComponentForm
model = DeviceBay
model_form = forms.DeviceBayForm
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
#
@@ -1871,7 +1801,8 @@ class InterfaceConnectionsListView(ObjectListView):
class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
permission_required = 'dcim.change_inventoryitem'
model = InventoryItem
form_class = forms.InventoryItemForm
parent_field = 'device'
model_form = forms.InventoryItemForm
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
@@ -1882,3 +1813,4 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
permission_required = 'dcim.delete_inventoryitem'
model = InventoryItem
parent_field = 'device'

View File

@@ -7,6 +7,18 @@ from django.utils.safestring import mark_safe
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction
def order_content_types(field):
"""
Order the list of available ContentTypes by application
"""
queryset = field.queryset.order_by('app_label', 'model')
field.choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
#
# Custom fields
#
class CustomFieldForm(forms.ModelForm):
class Meta:
@@ -16,9 +28,7 @@ class CustomFieldForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super(CustomFieldForm, self).__init__(*args, **kwargs)
# Organize the available ContentTypes
queryset = self.fields['obj_type'].queryset.order_by('app_label', 'model')
self.fields['obj_type'].choices = [(ct.pk, '{} > {}'.format(ct.app_label, ct.name)) for ct in queryset]
order_content_types(self.fields['obj_type'])
class CustomFieldChoiceAdmin(admin.TabularInline):
@@ -36,16 +46,43 @@ class CustomFieldAdmin(admin.ModelAdmin):
return ', '.join([ct.name for ct in obj.obj_type.all()])
#
# Graphs
#
@admin.register(Graph)
class GraphAdmin(admin.ModelAdmin):
list_display = ['name', 'type', 'weight', 'source']
#
# Export templates
#
class ExportTemplateForm(forms.ModelForm):
class Meta:
model = ExportTemplate
exclude = []
def __init__(self, *args, **kwargs):
super(ExportTemplateForm, self).__init__(*args, **kwargs)
# Format ContentType choices
order_content_types(self.fields['content_type'])
self.fields['content_type'].choices.insert(0, ('', '---------'))
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
form = ExportTemplateForm
#
# Topology maps
#
@admin.register(TopologyMap)
class TopologyMapAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'site']
@@ -54,6 +91,10 @@ class TopologyMapAdmin(admin.ModelAdmin):
}
#
# User actions
#
@admin.register(UserAction)
class UserActionAdmin(admin.ModelAdmin):
actions = None

View File

@@ -29,34 +29,47 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
for field_name, value in data.items():
cf = custom_fields[field_name]
try:
cf = custom_fields[field_name]
except KeyError:
raise ValidationError(
"Invalid custom field for {} objects: {}".format(content_type, field_name)
)
# Validate custom field name
if field_name not in custom_fields:
raise ValidationError("Invalid custom field for {} objects: {}".format(content_type, field_name))
# Data validation
if value not in [None, '']:
# Validate boolean
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError("Invalid value for boolean field {}: {}".format(field_name, value))
# Validate boolean
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError(
"Invalid value for boolean field {}: {}".format(field_name, value)
)
# Validate date
if cf.type == CF_TYPE_DATE:
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise ValidationError("Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(
field_name, value
))
# Validate date
if cf.type == CF_TYPE_DATE:
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
raise ValidationError(
"Invalid date for field {}: {}. (Required format is YYYY-MM-DD.)".format(field_name, value)
)
# Validate selected choice
if cf.type == CF_TYPE_SELECT:
try:
value = int(value)
except ValueError:
raise ValidationError("{}: Choice selections must be passed as integers.".format(field_name))
valid_choices = [c.pk for c in cf.choices.all()]
if value not in valid_choices:
raise ValidationError("Invalid choice for field {}: {}".format(field_name, value))
# Validate selected choice
if cf.type == CF_TYPE_SELECT:
try:
value = int(value)
except ValueError:
raise ValidationError(
"{}: Choice selections must be passed as integers.".format(field_name)
)
valid_choices = [c.pk for c in cf.choices.all()]
if value not in valid_choices:
raise ValidationError(
"Invalid choice for field {}: {}".format(field_name, value)
)
elif cf.required:
raise ValidationError("Required field {} cannot be empty.".format(field_name))
# Check for missing required fields
missing_fields = []

View File

@@ -7,7 +7,7 @@ from rest_framework import serializers
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,
ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, ReportResult, TopologyMap, UserAction,
)
from users.api.serializers import NestedUserSerializer
from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ValidatedModelSerializer
@@ -127,6 +127,41 @@ class WritableImageAttachmentSerializer(ValidatedModelSerializer):
return data
#
# Reports
#
class ReportResultSerializer(serializers.ModelSerializer):
class Meta:
model = ReportResult
fields = ['created', 'user', 'failed', 'data']
class NestedReportResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:report-detail',
lookup_field='report',
lookup_url_kwarg='pk'
)
class Meta:
model = ReportResult
fields = ['url', 'created', 'user', 'failed']
class ReportSerializer(serializers.Serializer):
module = serializers.CharField(max_length=255)
name = serializers.CharField(max_length=255)
description = serializers.CharField(max_length=255, required=False)
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
result = NestedReportResultSerializer()
class ReportDetailSerializer(ReportSerializer):
result = ReportResultSerializer()
#
# User actions
#

View File

@@ -16,6 +16,9 @@ class ExtrasRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = ExtrasRootView
# Field choices
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, base_name='field-choice')
# Graphs
router.register(r'graphs', views.GraphViewSet)
@@ -28,6 +31,9 @@ router.register(r'topology-maps', views.TopologyMapViewSet)
# Image attachments
router.register(r'image-attachments', views.ImageAttachmentViewSet)
# Reports
router.register(r'reports', views.ReportViewSet, base_name='report')
# Recent activity
router.register(r'recent-activity', views.RecentActivityViewSet)

View File

@@ -1,18 +1,36 @@
from __future__ import unicode_literals
from rest_framework.decorators import detail_route
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet
from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponse
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from extras import filters
from extras.models import ExportTemplate, Graph, ImageAttachment, TopologyMap, UserAction
from utilities.api import WritableSerializerMixin
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
from extras.reports import get_report, get_reports
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin
from . import serializers
#
# Field choices
#
class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(CustomField, ['type']),
(Graph, ['type']),
)
#
# Custom fields
#
class CustomFieldModelViewSet(ModelViewSet):
"""
Include the applicable set of CustomFields in the ModelViewSet context.
@@ -43,6 +61,10 @@ class CustomFieldModelViewSet(ModelViewSet):
return super(CustomFieldModelViewSet, self).get_queryset().prefetch_related('custom_field_values__field')
#
# Graphs
#
class GraphViewSet(WritableSerializerMixin, ModelViewSet):
queryset = Graph.objects.all()
serializer_class = serializers.GraphSerializer
@@ -50,12 +72,20 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet):
filter_class = filters.GraphFilter
#
# Export templates
#
class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ExportTemplate.objects.all()
serializer_class = serializers.ExportTemplateSerializer
filter_class = filters.ExportTemplateFilter
#
# Topology maps
#
class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
queryset = TopologyMap.objects.select_related('site')
serializer_class = serializers.TopologyMapSerializer
@@ -82,12 +112,96 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
return response
#
# Image attachments
#
class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
queryset = ImageAttachment.objects.all()
serializer_class = serializers.ImageAttachmentSerializer
write_serializer_class = serializers.WritableImageAttachmentSerializer
#
# Reports
#
class ReportViewSet(ViewSet):
permission_classes = [IsAuthenticatedOrLoginNotRequired]
_ignore_model_permissions = True
exclude_from_schema = True
lookup_value_regex = '[^/]+' # Allow dots
def _retrieve_report(self, pk):
# Read the PK as "<module>.<report>"
if '.' not in pk:
raise Http404
module_name, report_name = pk.split('.', 1)
# Raise a 404 on an invalid Report module/name
report = get_report(module_name, report_name)
if report is None:
raise Http404
return report
def list(self, request):
"""
Compile all reports and their related results (if any). Result data is deferred in the list view.
"""
report_list = []
# Iterate through all available Reports.
for module_name, reports in get_reports():
for report in reports:
# Attach the relevant ReportResult (if any) to each Report.
report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first()
report_list.append(report)
serializer = serializers.ReportSerializer(report_list, many=True, context={
'request': request,
})
return Response(serializer.data)
def retrieve(self, request, pk):
"""
Retrieve a single Report identified as "<module>.<report>".
"""
# Retrieve the Report and ReportResult, if any.
report = self._retrieve_report(pk)
report.result = ReportResult.objects.filter(report=report.full_name).first()
serializer = serializers.ReportDetailSerializer(report)
return Response(serializer.data)
@detail_route(methods=['post'])
def run(self, request, pk):
"""
Run a Report and create a new ReportResult, overwriting any previous result for the Report.
"""
# Check that the user has permission to run reports.
if not request.user.has_perm('extras.add_reportresult'):
raise PermissionDenied("This user does not have permission to run reports.")
# Retrieve and run the Report. This will create a new ReportResult.
report = self._retrieve_report(pk)
report.run()
serializer = serializers.ReportDetailSerializer(report)
return Response(serializer.data)
#
# User activity
#
class RecentActivityViewSet(ReadOnlyModelViewSet):
"""
List all UserActions to provide a log of recent activity.

View File

@@ -3,10 +3,11 @@ from __future__ import unicode_literals
# Models which support custom fields
CUSTOMFIELD_MODELS = (
'provider', 'circuit', # Circuits
'site', 'rack', 'devicetype', 'device', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM
'provider', 'circuit', # Circuits
'tenant', # Tenants
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
)
# Custom field types
@@ -37,10 +38,12 @@ GRAPH_TYPE_CHOICES = (
# Models which support export templates
EXPORTTEMPLATE_MODELS = [
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
'provider', 'circuit', # Circuits
'tenant', # Tenants
'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM
'consoleport', 'powerport', 'interfaceconnection', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
]
# User action types
@@ -60,3 +63,17 @@ ACTION_CHOICES = (
(ACTION_DELETE, 'deleted'),
(ACTION_BULK_DELETE, 'bulk deleted'),
)
# Report logging levels
LOG_DEFAULT = 0
LOG_SUCCESS = 10
LOG_INFO = 20
LOG_WARNING = 30
LOG_FAILURE = 40
LOG_LEVEL_CODES = {
LOG_DEFAULT: 'default',
LOG_SUCCESS: 'success',
LOG_INFO: 'info',
LOG_WARNING: 'warning',
LOG_FAILURE: 'failure',
}

View File

@@ -11,7 +11,7 @@ from django.core.management.base import BaseCommand
from django.db.models import Model
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users']
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
BANNER_TEXT = """### NetBox interactive shell ({node})
### Python {python} | Django {django} | NetBox {netbox}

View File

@@ -0,0 +1,47 @@
from __future__ import unicode_literals
from django.core.management.base import BaseCommand
from django.utils import timezone
from extras.models import ReportResult
from extras.reports import get_reports
class Command(BaseCommand):
help = "Run a report to validate data in NetBox"
def add_arguments(self, parser):
parser.add_argument('reports', nargs='+', help="Report(s) to run")
def handle(self, *args, **options):
# Gather all available reports
reports = get_reports()
# Run reports
for module_name, report_list in reports:
for report in report_list:
if module_name in options['reports'] or report.full_name in options['reports']:
# Run the report and create a new ReportResult
self.stdout.write(
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
)
report.run()
# Report on success/failure
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS')
for test_name, attrs in report.result.data.items():
self.stdout.write(
"\t{}: {} success, {} info, {} warning, {} failure".format(
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
)
)
self.stdout.write(
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
)
# Wrap things up
self.stdout.write(
"[{:%H:%M:%S}] Finished".format(timezone.now())
)

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-09-26 21:25
from __future__ import unicode_literals
from distutils.version import StrictVersion
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import connection, migrations, models
import django.db.models.deletion
from django.db.utils import OperationalError
def verify_postgresql_version(apps, schema_editor):
"""
Verify that PostgreSQL is version 9.4 or higher.
"""
try:
with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()")
row = cursor.fetchone()
pg_version = row[0].split()[1]
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
except OperationalError:
pass
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('extras', '0007_unicode_literals'),
]
operations = [
migrations.RunPython(verify_postgresql_version),
migrations.CreateModel(
name='ReportResult',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('report', models.CharField(max_length=255, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
('failed', models.BooleanField()),
('data', django.contrib.postgres.fields.jsonb.JSONField()),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['report'],
},
),
]

View File

@@ -6,6 +6,7 @@ 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.contrib.postgres.fields import JSONField
from django.core.validators import ValidationError
from django.db import models
from django.db.models import Q
@@ -273,6 +274,7 @@ class TopologyMap(models.Model):
# Construct the graph
graph = graphviz.Graph()
graph.graph_attr['ranksep'] = '1'
seen = set()
for i, device_set in enumerate(self.device_sets):
subgraph = graphviz.Graph(name='sg{}'.format(i))
@@ -287,6 +289,9 @@ class TopologyMap(models.Model):
devices = []
for query in device_set.strip(';').split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query).select_related('device_role')
# Remove duplicate devices
devices = [d for d in devices if d.id not in seen]
seen.update([d.id for d in devices])
for d in devices:
bg_color = '#{}'.format(d.device_role.color)
fg_color = '#{}'.format(foreground_color(d.device_role.color))
@@ -388,6 +393,24 @@ class ImageAttachment(models.Model):
return None
#
# Report results
#
class ReportResult(models.Model):
"""
This model stores the results from running a user-defined report.
"""
report = models.CharField(max_length=255, unique=True)
created = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='+', blank=True, null=True)
failed = models.BooleanField()
data = JSONField()
class Meta:
ordering = ['report']
#
# User actions
#

179
netbox/extras/reports.py Normal file
View File

@@ -0,0 +1,179 @@
from __future__ import unicode_literals
from collections import OrderedDict
import importlib
import inspect
import pkgutil
from django.conf import settings
from django.utils import timezone
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_LEVEL_CODES, LOG_SUCCESS, LOG_WARNING
from .models import ReportResult
def is_report(obj):
"""
Returns True if the given object is a Report.
"""
if obj in Report.__subclasses__():
return True
return False
def get_report(module_name, report_name):
"""
Return a specific report from within a module.
"""
module = importlib.import_module('reports.{}'.format(module_name))
report = getattr(module, report_name, None)
if report is None:
return None
return report()
def get_reports():
"""
Compile a list of all reports available across all modules in the reports path. Returns a list of tuples:
[
(module_name, (report, report, report, ...)),
(module_name, (report, report, report, ...)),
...
]
"""
module_list = []
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, is_pkg in pkgutil.walk_packages([settings.REPORTS_ROOT]):
module = importlib.import_module('reports.{}'.format(module_name))
report_list = [cls() for _, cls in inspect.getmembers(module, is_report)]
module_list.append((module_name, report_list))
return module_list
class Report(object):
"""
NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
report must have one or more test methods named `test_*`.
The `_results` attribute of a completed report will take the following form:
{
'test_bar': {
'failures': 42,
'log': [
(<datetime>, <level>, <object>, <message>),
...
]
},
'test_foo': {
'failures': 0,
'log': [
(<datetime>, <level>, <object>, <message>),
...
]
}
}
"""
description = None
def __init__(self):
self._results = OrderedDict()
self.active_test = None
self.failed = False
# Compile test methods and initialize results skeleton
test_methods = []
for method in dir(self):
if method.startswith('test_') and callable(getattr(self, method)):
test_methods.append(method)
self._results[method] = OrderedDict([
('success', 0),
('info', 0),
('warning', 0),
('failure', 0),
('log', []),
])
if not test_methods:
raise Exception("A report must contain at least one test method.")
self.test_methods = test_methods
@property
def module(self):
return self.__module__.rsplit('.', 1)[1]
@property
def name(self):
return self.__class__.__name__
@property
def full_name(self):
return '.'.join([self.module, self.name])
def _log(self, obj, message, level=LOG_DEFAULT):
"""
Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
"""
if level not in LOG_LEVEL_CODES:
raise Exception("Unknown logging level: {}".format(level))
self._results[self.active_test]['log'].append((
timezone.now().isoformat(),
LOG_LEVEL_CODES.get(level),
str(obj) if obj else None,
obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
message,
))
def log(self, message):
"""
Log a message which is not associated with a particular object.
"""
self._log(None, message, level=LOG_DEFAULT)
def log_success(self, obj, message=None):
"""
Record a successful test against an object. Logging a message is optional.
"""
if message:
self._log(obj, message, level=LOG_SUCCESS)
self._results[self.active_test]['success'] += 1
def log_info(self, obj, message):
"""
Log an informational message.
"""
self._log(obj, message, level=LOG_INFO)
self._results[self.active_test]['info'] += 1
def log_warning(self, obj, message):
"""
Log a warning.
"""
self._log(obj, message, level=LOG_WARNING)
self._results[self.active_test]['warning'] += 1
def log_failure(self, obj, message):
"""
Log a failure. Calling this method will automatically mark the report as failed.
"""
self._log(obj, message, level=LOG_FAILURE)
self._results[self.active_test]['failure'] += 1
self.failed = True
def run(self):
"""
Run the report and return its results. Each test method will be executed in order.
"""
for method_name in self.test_methods:
self.active_test = method_name
test_method = getattr(self, method_name)
test_method()
# Delete any previous ReportResult and create a new one to record the result.
ReportResult.objects.filter(report=self.full_name).delete()
result = ReportResult(report=self.full_name, failed=self.failed, data=self._results)
result.save()
self.result = result

View File

@@ -20,19 +20,6 @@ class RPCClient(object):
except AttributeError:
raise Exception("Specified device ({}) does not have a primary IP defined.".format(device))
def get_lldp_neighbors(self):
"""
Returns a list of dictionaries, each representing an LLDP neighbor adjacency.
{
'local-interface': <str>,
'name': <str>,
'remote-interface': <str>,
'chassis-id': <str>,
}
"""
raise NotImplementedError("Feature not implemented for this platform.")
def get_inventory(self):
"""
Returns a dictionary representing the device chassis and installed inventory items.
@@ -123,30 +110,6 @@ class JunosNC(RPCClient):
# Close the connection to the device
self.manager.close_session()
def get_lldp_neighbors(self):
rpc_reply = self.manager.dispatch('get-lldp-neighbors-information')
lldp_neighbors_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['lldp-neighbors-information']['lldp-neighbor-information']
result = []
for neighbor_raw in lldp_neighbors_raw:
neighbor = dict()
neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id')
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:
# Older versions of Junos report on interface ID instead of description
neighbor['remote-interface'] = neighbor_raw.get('lldp-remote-port-id')
neighbor['chassis-id'] = neighbor_raw.get('lldp-remote-chassis-id')
result.append(neighbor)
return result
def get_inventory(self):
def glean_items(node, depth=0):

View File

@@ -12,4 +12,9 @@ urlpatterns = [
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'),
# Reports
url(r'^reports/$', views.ReportListView.as_view(), name='report_list'),
url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
]

View File

@@ -1,17 +1,27 @@
from __future__ import unicode_literals
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.shortcuts import get_object_or_404
from django.contrib import messages
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.views.generic import View
from utilities.forms import ConfirmationForm
from utilities.views import ObjectDeleteView, ObjectEditView
from .forms import ImageAttachmentForm
from .models import ImageAttachment
from .models import ImageAttachment, ReportResult, UserAction
from .reports import get_report, get_reports
#
# Image attachments
#
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.change_imageattachment'
model = ImageAttachment
form_class = ImageAttachmentForm
model_form = ImageAttachmentForm
def alter_obj(self, imageattachment, request, args, kwargs):
if not imageattachment.pk:
@@ -30,3 +40,79 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
def get_return_url(self, request, imageattachment):
return imageattachment.parent.get_absolute_url()
#
# Reports
#
class ReportListView(View):
"""
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
"""
def get(self, request):
reports = get_reports()
results = {r.report: r for r in ReportResult.objects.all()}
ret = []
for module, report_list in reports:
module_reports = []
for report in report_list:
report.result = results.get(report.full_name, None)
module_reports.append(report)
ret.append((module, module_reports))
return render(request, 'extras/report_list.html', {
'reports': ret,
})
class ReportView(View):
"""
Display a single Report and its associated ReportResult (if any).
"""
def get(self, request, name):
# Retrieve the Report by "<module>.<report>"
module_name, report_name = name.split('.')
report = get_report(module_name, report_name)
if report is None:
raise Http404
# Attach the ReportResult (if any)
report.result = ReportResult.objects.filter(report=report.full_name).first()
return render(request, 'extras/report.html', {
'report': report,
'run_form': ConfirmationForm(),
})
class ReportRunView(PermissionRequiredMixin, View):
"""
Run a Report and record a new ReportResult.
"""
permission_required = 'extras.add_reportresult'
def post(self, request, name):
# Retrieve the Report by "<module>.<report>"
module_name, report_name = name.split('.')
report = get_report(module_name, report_name)
if report is None:
raise Http404
form = ConfirmationForm(request.POST)
if form.is_valid():
# Run the Report. A new ReportResult is created.
report.run()
result = 'failed' if report.failed else 'passed'
msg = "Ran report {} ({})".format(report.full_name, result)
messages.success(request, mark_safe(msg))
UserAction.objects.log_create(request.user, report.result, msg)
return redirect('extras:report', name=report.full_name)

View File

@@ -12,6 +12,7 @@ from ipam.models import (
)
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.api.serializers import NestedVirtualMachineSerializer
#
@@ -239,12 +240,22 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
# IP addresses
#
class IPAddressInterfaceSerializer(InterfaceSerializer):
virtual_machine = NestedVirtualMachineSerializer()
class Meta(InterfaceSerializer.Meta):
fields = [
'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination',
]
class IPAddressSerializer(CustomFieldModelSerializer):
vrf = NestedVRFSerializer()
tenant = NestedTenantSerializer()
status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES)
role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES)
interface = InterfaceSerializer()
interface = IPAddressInterfaceSerializer()
class Meta:
model = IPAddress
@@ -261,6 +272,7 @@ class NestedIPAddressSerializer(serializers.ModelSerializer):
model = IPAddress
fields = ['id', 'url', 'family', 'address']
IPAddressSerializer._declared_fields['nat_inside'] = NestedIPAddressSerializer()
IPAddressSerializer._declared_fields['nat_outside'] = NestedIPAddressSerializer()
@@ -295,12 +307,13 @@ class AvailableIPSerializer(serializers.Serializer):
class ServiceSerializer(serializers.ModelSerializer):
device = NestedDeviceSerializer()
virtual_machine = NestedVirtualMachineSerializer()
protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
ipaddresses = NestedIPAddressSerializer(many=True)
class Meta:
model = Service
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
@@ -308,4 +321,4 @@ class WritableServiceSerializer(serializers.ModelSerializer):
class Meta:
model = Service
fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']

View File

@@ -16,6 +16,9 @@ class IPAMRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = IPAMRootView
# Field choices
router.register(r'_choices', views.IPAMFieldChoicesViewSet, base_name='field-choice')
# VRFs
router.register(r'vrfs', views.VRFViewSet)

View File

@@ -12,10 +12,24 @@ from django.shortcuts import get_object_or_404
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from ipam import filters
from extras.api.views import CustomFieldModelViewSet
from utilities.api import WritableSerializerMixin
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
#
# Field choices
#
class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(Aggregate, ['family']),
(Prefix, ['family', 'status']),
(IPAddress, ['family', 'status', 'role']),
(VLAN, ['status']),
(Service, ['protocol']),
)
#
# VRFs
#
@@ -98,7 +112,7 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
# Create the new IP address
data = request.data.copy()
data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
data['vrf'] = prefix.vrf
data['vrf'] = prefix.vrf.pk if prefix.vrf else None
serializer = serializers.WritableIPAddressSerializer(data=data)
if serializer.is_valid():
serializer.save()
@@ -134,7 +148,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
#
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
queryset = IPAddress.objects.select_related(
'vrf__tenant', 'tenant', 'nat_inside'
).prefetch_related(
'interface__device', 'interface__virtual_machine'
)
serializer_class = serializers.IPAddressSerializer
write_serializer_class = serializers.WritableIPAddressSerializer
filter_class = filters.IPAddressFilter

View File

@@ -39,6 +39,7 @@ IPADDRESS_ROLE_VIP = 40
IPADDRESS_ROLE_VRRP = 41
IPADDRESS_ROLE_HSRP = 42
IPADDRESS_ROLE_GLBP = 43
IPADDRESS_ROLE_CARP = 44
IPADDRESS_ROLE_CHOICES = (
(IPADDRESS_ROLE_LOOPBACK, 'Loopback'),
(IPADDRESS_ROLE_SECONDARY, 'Secondary'),
@@ -47,6 +48,7 @@ IPADDRESS_ROLE_CHOICES = (
(IPADDRESS_ROLE_VRRP, 'VRRP'),
(IPADDRESS_ROLE_HSRP, 'HSRP'),
(IPADDRESS_ROLE_GLBP, 'GLBP'),
(IPADDRESS_ROLE_CARP, 'CARP'),
)
# VLAN statuses

View File

@@ -10,6 +10,7 @@ from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
from virtualization.models import VirtualMachine
from .models import (
Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF,
@@ -237,6 +238,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
name='interface__virtual_machine',
queryset=VirtualMachine.objects.all(),
label='Virtual machine (ID)',
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
name='interface__virtual_machine__name',
queryset=VirtualMachine.objects.all(),
to_field_name='name',
label='Virtual machine (name)',
)
interface_id = django_filters.ModelMultipleChoiceFilter(
queryset=Interface.objects.all(),
label='Interface (ID)',
@@ -372,6 +384,16 @@ class ServiceFilter(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualMachine.objects.all(),
label='Virtual machine (ID)',
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
name='virtual_machine__name',
queryset=VirtualMachine.objects.all(),
to_field_name='name',
label='Virtual machine (name)',
)
class Meta:
model = Service

View File

@@ -70,7 +70,7 @@
"family": 4,
"address": "10.0.255.1/32",
"vrf": null,
"interface": 3,
"interface_id": 3,
"nat_inside": null,
"description": ""
}
@@ -84,7 +84,7 @@
"family": 4,
"address": "169.254.254.1/31",
"vrf": null,
"interface": 4,
"interface_id": 4,
"nat_inside": null,
"description": ""
}
@@ -98,7 +98,7 @@
"family": 4,
"address": "10.0.255.2/32",
"vrf": null,
"interface": 185,
"interface_id": 185,
"nat_inside": null,
"description": ""
}
@@ -112,7 +112,7 @@
"family": 4,
"address": "169.254.1.1/31",
"vrf": null,
"interface": 213,
"interface_id": 213,
"nat_inside": null,
"description": ""
}
@@ -126,7 +126,7 @@
"family": 4,
"address": "10.0.254.1/24",
"vrf": null,
"interface": 12,
"interface_id": 12,
"nat_inside": null,
"description": ""
}
@@ -140,7 +140,7 @@
"family": 4,
"address": "10.15.21.1/31",
"vrf": null,
"interface": 218,
"interface_id": 218,
"nat_inside": null,
"description": ""
}
@@ -154,7 +154,7 @@
"family": 4,
"address": "10.15.21.2/31",
"vrf": null,
"interface": 9,
"interface_id": 9,
"nat_inside": null,
"description": ""
}
@@ -168,7 +168,7 @@
"family": 4,
"address": "10.15.22.1/31",
"vrf": null,
"interface": 8,
"interface_id": 8,
"nat_inside": null,
"description": ""
}
@@ -182,7 +182,7 @@
"family": 4,
"address": "10.15.20.1/31",
"vrf": null,
"interface": 7,
"interface_id": 7,
"nat_inside": null,
"description": ""
}
@@ -196,7 +196,7 @@
"family": 4,
"address": "10.16.20.1/31",
"vrf": null,
"interface": 216,
"interface_id": 216,
"nat_inside": null,
"description": ""
}
@@ -210,7 +210,7 @@
"family": 4,
"address": "10.15.22.2/31",
"vrf": null,
"interface": 206,
"interface_id": 206,
"nat_inside": null,
"description": ""
}
@@ -224,7 +224,7 @@
"family": 4,
"address": "10.16.22.1/31",
"vrf": null,
"interface": 217,
"interface_id": 217,
"nat_inside": null,
"description": ""
}
@@ -238,7 +238,7 @@
"family": 4,
"address": "10.16.22.2/31",
"vrf": null,
"interface": 205,
"interface_id": 205,
"nat_inside": null,
"description": ""
}
@@ -252,7 +252,7 @@
"family": 4,
"address": "10.16.20.2/31",
"vrf": null,
"interface": 211,
"interface_id": 211,
"nat_inside": null,
"description": ""
}
@@ -266,7 +266,7 @@
"family": 4,
"address": "10.15.22.2/31",
"vrf": null,
"interface": 212,
"interface_id": 212,
"nat_inside": null,
"description": ""
}
@@ -280,7 +280,7 @@
"family": 4,
"address": "10.0.254.2/32",
"vrf": null,
"interface": 188,
"interface_id": 188,
"nat_inside": null,
"description": ""
}
@@ -294,7 +294,7 @@
"family": 4,
"address": "169.254.1.1/31",
"vrf": null,
"interface": 200,
"interface_id": 200,
"nat_inside": null,
"description": ""
}
@@ -308,7 +308,7 @@
"family": 4,
"address": "169.254.1.2/31",
"vrf": null,
"interface": 194,
"interface_id": 194,
"nat_inside": null,
"description": ""
}

View File

@@ -13,6 +13,7 @@ from utilities.forms import (
ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
add_blank_choice,
)
from virtualization.models import VirtualMachine
from .models import (
Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role,
Service, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
@@ -25,11 +26,8 @@ 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)]
PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)])
IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)])
#
@@ -99,6 +97,17 @@ class RIRForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'is_private']
class RIRCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = RIR
fields = ['name', 'slug', 'is_private']
help_texts = {
'name': 'RIR name',
}
class RIRFilterForm(BootstrapMixin, forms.Form):
is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[
('', '---------'),
@@ -171,6 +180,17 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug']
class RoleCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = Role
fields = ['name', 'slug']
help_texts = {
'name': 'Role name',
}
#
# Prefixes
#
@@ -377,50 +397,9 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
#
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(
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
chains=(
('device', 'interface_device'),
),
required=False,
widget=APISelect(
api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
)
required=False
)
nat_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
@@ -479,13 +458,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
obj_label='address'
)
)
primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
'nat_inside', 'tenant_group', 'tenant',
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
'nat_rack', 'nat_inside', 'tenant_group', 'tenant',
]
def __init__(self, *args, **kwargs):
@@ -493,10 +472,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
if instance and instance.interface is not None:
initial['interface_site'] = instance.interface.device.site
initial['interface_rack'] = instance.interface.device.rack
initial['interface_device'] = instance.interface.device
if instance and instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
@@ -507,22 +482,30 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
self.fields['vrf'].empty_label = 'Global'
# Initialize primary_for_device if IP address is already assigned
if self.instance.interface is not None:
device = self.instance.interface.device
# Limit interface selections to those belonging to the parent device/VM
if self.instance and self.instance.interface:
self.fields['interface'].queryset = Interface.objects.filter(
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
)
else:
self.fields['interface'].choices = []
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.interface is not None:
parent = self.instance.interface.parent
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.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
):
self.initial['primary_for_device'] = True
self.initial['primary_for_parent'] = True
def clean(self):
super(IPAddressForm, self).clean()
# Primary IP assignment is only available if an interface has been assigned.
if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'):
if self.cleaned_data.get('primary_for_parent') 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."
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
def save(self, *args, **kwargs):
@@ -530,13 +513,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
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 self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4:
device.primary_ip4 = ipaddress
parent.primary_ip4 = ipaddress
else:
device.primary_ip6 = ipaddress
device.save()
parent.primary_ip6 = ipaddress
parent.save()
# Clear assignment as primary for device if set.
else:
@@ -554,7 +537,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
return ipaddress
class IPAddressPatternForm(BootstrapMixin, forms.Form):
class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
pattern = ExpandableIPAddressField(label='Address pattern')
@@ -606,6 +589,15 @@ class IPAddressCSVForm(forms.ModelForm):
'invalid_choice': 'Device not found.',
}
)
virtual_machine = forms.ModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
to_field_name='name',
help_text='Name of assigned virtual machine',
error_messages={
'invalid_choice': 'Virtual machine not found.',
}
)
interface_name = forms.CharField(
help_text='Name of assigned interface',
required=False
@@ -617,30 +609,47 @@ class IPAddressCSVForm(forms.ModelForm):
class Meta:
model = IPAddress
fields = ['address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description']
fields = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
'description',
]
def clean(self):
super(IPAddressCSVForm, self).clean()
device = self.cleaned_data.get('device')
virtual_machine = self.cleaned_data.get('virtual_machine')
interface_name = self.cleaned_data.get('interface_name')
is_primary = self.cleaned_data.get('is_primary')
# Validate interface
if device and interface_name:
if interface_name and device:
try:
self.instance.interface = Interface.objects.get(device=device, name=interface_name)
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface {} for device {}".format(interface_name, device))
elif device and not interface_name:
raise forms.ValidationError("Device set ({}) but interface missing".format(device))
elif interface_name and not device:
raise forms.ValidationError("Interface set ({}) but device missing or invalid".format(interface_name))
raise forms.ValidationError("Invalid interface {} for device {}".format(
interface_name, device
))
elif interface_name and virtual_machine:
try:
self.instance.interface = Interface.objects.get(virtual_machine=virtual_machine, name=interface_name)
except Interface.DoesNotExist:
raise forms.ValidationError("Invalid interface {} for virtual machine {}".format(
interface_name, virtual_machine
))
elif interface_name:
raise forms.ValidationError("Interface given ({}) but parent device/virtual machine not specified".format(
interface_name
))
elif device:
raise forms.ValidationError("Device specified ({}) but interface missing".format(device))
elif virtual_machine:
raise forms.ValidationError("Virtual machine specified ({}) but interface missing".format(virtual_machine))
# Validate is_primary
if is_primary and not device:
raise forms.ValidationError("No device specified; cannot set as primary IP")
if is_primary and not device and not virtual_machine:
raise forms.ValidationError("No device or virtual machine specified; cannot set as primary IP")
def save(self, *args, **kwargs):
@@ -650,17 +659,22 @@ class IPAddressCSVForm(forms.ModelForm):
device=self.cleaned_data['device'],
name=self.cleaned_data['interface_name']
)
elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']:
self.instance.interface = Interface.objects.get(
virtual_machine=self.cleaned_data['virtual_machine'],
name=self.cleaned_data['interface_name']
)
ipaddress = super(IPAddressCSVForm, self).save(*args, **kwargs)
# Set as primary for device
# Set as primary for device/VM
if self.cleaned_data['is_primary']:
device = self.cleaned_data['device']
parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine']
if self.instance.address.version == 4:
device.primary_ip4 = ipaddress
parent.primary_ip4 = ipaddress
elif self.instance.address.version == 6:
device.primary_ip6 = ipaddress
device.save()
parent.primary_ip6 = ipaddress
parent.save()
return ipaddress
@@ -726,6 +740,26 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
fields = ['site', 'name', 'slug']
class VLANGroupCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='name',
help_text='Name of parent site',
error_messages={
'invalid_choice': 'Site not found.',
}
)
slug = SlugField()
class Meta:
model = VLANGroup
fields = ['site', 'name', 'slug']
help_texts = {
'name': 'Name of VLAN group',
}
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('vlan_groups')),
@@ -898,5 +932,14 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
super(ServiceForm, self).__init__(*args, **kwargs)
# Limit IP address choices to those assigned to interfaces of the parent device
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device)
# Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__device=self.instance.device
)
elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__virtual_machine=self.instance.virtual_machine
)
else:
self.fields['ipaddresses'].choices = []

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-08-31 15:44
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0001_virtualization'),
('ipam', '0018_remove_service_uniqueness_constraint'),
]
operations = [
migrations.AlterModelOptions(
name='service',
options={'ordering': ['protocol', 'port']},
),
migrations.AddField(
model_name='service',
name='virtual_machine',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'),
),
migrations.AlterField(
model_name='service',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-10-09 20:02
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ipam', '0019_virtualization'),
]
operations = [
migrations.AlterField(
model_name='ipaddress',
name='role',
field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP'), (44, 'CARP')], help_text='The functional role of this IP', null=True, verbose_name='Role'),
),
]

View File

@@ -3,6 +3,7 @@ import netaddr
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -14,10 +15,10 @@ from dcim.models import Interface
from extras.models import CustomFieldModel, CustomFieldValue
from tenancy.models import Tenant
from utilities.models import CreatedUpdatedModel
from utilities.sql import NullsFirstQuerySet
from utilities.utils import csv_format
from .constants import *
from .fields import IPNetworkField, IPAddressField
from .querysets import PrefixQuerySet
@python_2_unicode_compatible
@@ -188,41 +189,6 @@ class Role(models.Model):
return self.vlans.count()
class PrefixQuerySet(NullsFirstQuerySet):
def annotate_depth(self, limit=None):
"""
Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable
to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces
performance issues at scale.
Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet
modifications.
"""
queryset = self
stack = []
for p in queryset:
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
if prev_p is not None:
while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix:
stack.pop()
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
break
if prev_p is not None:
prev_p.has_children = True
stack.append(p)
p.depth = len(stack) - 1
if limit is None:
return queryset
return list(filter(lambda p: p.depth <= limit, queryset))
@python_2_unicode_compatible
class Prefix(CreatedUpdatedModel, CustomFieldModel):
"""
@@ -418,7 +384,8 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
objects = IPAddressManager()
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description',
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
'description',
]
class Meta:
@@ -473,6 +440,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
self.get_status_display(),
self.get_role_display(),
self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.interface.name if self.interface else None,
is_primary,
self.description,
@@ -484,6 +452,12 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel):
return self.interface.device
return None
@property
def virtual_machine(self):
if self.interface:
return self.interface.virtual_machine
return None
def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status]
@@ -512,6 +486,16 @@ class VLANGroup(models.Model):
def get_absolute_url(self):
return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
def get_next_available_vid(self):
"""
Return the first available VLAN ID (1-4094) in the group.
"""
vids = [vlan['vid'] for vlan in self.vlans.order_by('vid').values('vid')]
for i in range(1, 4095):
if i not in vids:
return i
return None
@python_2_unicode_compatible
class VLAN(CreatedUpdatedModel, CustomFieldModel):
@@ -586,20 +570,59 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
@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
to one or more specific IPAddresses belonging to the Device.
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
optionally be tied to one or more specific IPAddresses belonging to its parent.
"""
device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device')
name = models.CharField(max_length=30)
protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES)
port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)],
verbose_name='Port number')
ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True,
verbose_name='IP addresses')
description = models.CharField(max_length=100, blank=True)
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='services',
verbose_name='device',
null=True,
blank=True
)
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='services',
null=True,
blank=True
)
name = models.CharField(
max_length=30
)
protocol = models.PositiveSmallIntegerField(
choices=IP_PROTOCOL_CHOICES
)
port = models.PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(65535)],
verbose_name='Port number'
)
ipaddresses = models.ManyToManyField(
to='ipam.IPAddress',
related_name='services',
blank=True,
verbose_name='IP addresses'
)
description = models.CharField(
max_length=100,
blank=True
)
class Meta:
ordering = ['device', 'protocol', 'port']
ordering = ['protocol', 'port']
def __str__(self):
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
@property
def parent(self):
return self.device or self.virtual_machine
def clean(self):
# A Service must belong to a Device *or* to a VirtualMachine
if self.device and self.virtual_machine:
raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
if not self.device and not self.virtual_machine:
raise ValidationError("A service must be associated with either a device or a virtual machine.")

38
netbox/ipam/querysets.py Normal file
View File

@@ -0,0 +1,38 @@
from __future__ import unicode_literals
from utilities.sql import NullsFirstQuerySet
class PrefixQuerySet(NullsFirstQuerySet):
def annotate_depth(self, limit=None):
"""
Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable
to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces
performance issues at scale.
Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet
modifications.
"""
queryset = self
stack = []
for p in queryset:
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
if prev_p is not None:
while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix:
stack.pop()
try:
prev_p = stack[-1]
except IndexError:
prev_p = None
break
if prev_p is not None:
prev_p.has_children = True
stack.append(p)
p.depth = len(stack) - 1
if limit is None:
return queryset
return list(filter(lambda p: p.depth <= limit, queryset))

View File

@@ -34,7 +34,7 @@ RIR_ACTIONS = """
UTILIZATION_GRAPH = """
{% load helpers %}
{% if record.pk %}{% utilization_graph value %}{% else %}&mdash;{% endif %}
{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
"""
ROLE_ACTIONS = """
@@ -71,15 +71,15 @@ IPADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}{% if prefix.tenant %}&tenant={{ prefix.tenant.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %}
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available
{% endif %}
"""
IPADDRESS_DEVICE = """
IPADDRESS_PARENT = """
{% if record.interface %}
<a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
{% else %}
&mdash;
{% endif %}
@@ -120,6 +120,13 @@ VLAN_ROLE_LINK = """
"""
VLANGROUP_ACTIONS = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
{% endwith %}
{% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@@ -203,10 +210,10 @@ class AggregateTable(BaseTable):
class AggregateDetailTable(AggregateTable):
child_count = tables.Column(verbose_name='Prefixes')
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
class Meta(AggregateTable.Meta):
fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description')
fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description')
#
@@ -249,10 +256,10 @@ class PrefixTable(BaseTable):
class PrefixDetailTable(PrefixTable):
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False)
class Meta(PrefixTable.Meta):
fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description')
fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description')
#
@@ -265,12 +272,12 @@ class IPAddressTable(BaseTable):
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
tenant = tables.TemplateColumn(TENANT_LINK)
device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
interface = tables.Column(orderable=False)
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description')
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
}
@@ -283,7 +290,7 @@ class IPAddressDetailTable(IPAddressTable):
class Meta(IPAddressTable.Meta):
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description',
)

View File

@@ -21,6 +21,7 @@ urlpatterns = [
# RIRs
url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'),
url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'),
url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'),
url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
url(r'^rirs/(?P<slug>[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'),
@@ -37,6 +38,7 @@ urlpatterns = [
# Roles
url(r'^roles/$', views.RoleListView.as_view(), name='role_list'),
url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'),
url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'),
url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
url(r'^roles/(?P<slug>[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'),
@@ -65,6 +67,7 @@ urlpatterns = [
# VLAN groups
url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),

View File

@@ -10,12 +10,14 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views.generic import View
from dcim.models import Device
from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .constants import IPADDRESS_ROLE_ANYCAST
from .models import (
Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role,
Service, VLAN, VLANGroup, VRF,
@@ -117,7 +119,7 @@ class VRFView(View):
class VRFCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vrf'
model = VRF
form_class = forms.VRFForm
model_form = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
default_return_url = 'ipam:vrf_list'
@@ -249,7 +251,7 @@ class RIRListView(ObjectListView):
class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_rir'
model = RIR
form_class = forms.RIRForm
model_form = forms.RIRForm
def get_return_url(self, request, obj):
return reverse('ipam:rir_list')
@@ -259,6 +261,13 @@ class RIREditView(RIRCreateView):
permission_required = 'ipam.change_rir'
class RIRBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_rir'
model_form = forms.RIRCSVForm
table = tables.RIRTable
default_return_url = 'ipam:rir_list'
class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_rir'
cls = RIR
@@ -285,11 +294,12 @@ class AggregateListView(ObjectListView):
ipv4_total = 0
ipv6_total = 0
for a in self.queryset:
if a.prefix.version == 4:
ipv4_total += a.prefix.size
elif a.prefix.version == 6:
ipv6_total += a.prefix.size / 2 ** 64
for aggregate in self.queryset:
if aggregate.prefix.version == 6:
# Report equivalent /64s for IPv6 to keep things sane
ipv6_total += int(aggregate.prefix.size / 2 ** 64)
else:
ipv4_total += aggregate.prefix.size
return {
'ipv4_total': ipv4_total,
@@ -313,7 +323,7 @@ class AggregateView(View):
)
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
prefix_table = tables.PrefixTable(child_prefixes)
prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
prefix_table.base_columns['pk'].visible = True
@@ -340,7 +350,7 @@ class AggregateView(View):
class AggregateCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_aggregate'
model = Aggregate
form_class = forms.AggregateForm
model_form = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
default_return_url = 'ipam:aggregate_list'
@@ -394,7 +404,7 @@ class RoleListView(ObjectListView):
class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_role'
model = Role
form_class = forms.RoleForm
model_form = forms.RoleForm
def get_return_url(self, request, obj):
return reverse('ipam:role_list')
@@ -404,6 +414,13 @@ class RoleEditView(RoleCreateView):
permission_required = 'ipam.change_role'
class RoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_role'
model_form = forms.RoleCSVForm
table = tables.RoleTable
default_return_url = 'ipam:role_list'
class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_role'
cls = Role
@@ -472,11 +489,11 @@ class PrefixView(View):
child_prefixes = Prefix.objects.filter(
vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix)
).select_related(
'site', 'role'
'site', 'vlan', 'role',
).annotate_depth(limit=0)
if child_prefixes:
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
child_prefix_table = tables.PrefixTable(child_prefixes)
child_prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
child_prefix_table.base_columns['pk'].visible = True
@@ -547,7 +564,7 @@ class PrefixIPAddressesView(View):
class PrefixCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_prefix'
model = Prefix
form_class = forms.PrefixForm
model_form = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
default_return_url = 'ipam:prefix_list'
@@ -594,7 +611,11 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
queryset = IPAddress.objects.select_related(
'vrf__tenant', 'tenant', 'nat_inside'
).prefetch_related(
'interface__device', 'interface__virtual_machine'
)
filter = filters.IPAddressFilter
filter_form = forms.IPAddressFilterForm
table = tables.IPAddressDetailTable
@@ -605,7 +626,7 @@ class IPAddressView(View):
def get(self, request, pk):
ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk)
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(
@@ -622,12 +643,17 @@ class IPAddressView(View):
).exclude(
pk=ipaddress.pk
).select_related(
'interface__device', 'nat_inside'
'nat_inside'
).prefetch_related(
'interface__device'
)
# Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
duplicate_ips = duplicate_ips.exclude(role=IPADDRESS_ROLE_ANYCAST)
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Related IP table
related_ips = IPAddress.objects.select_related(
related_ips = IPAddress.objects.prefetch_related(
'interface__device'
).exclude(
address=str(ipaddress.address)
@@ -647,10 +673,21 @@ class IPAddressView(View):
class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_ipaddress'
model = IPAddress
form_class = forms.IPAddressForm
model_form = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html'
default_return_url = 'ipam:ipaddress_list'
def alter_obj(self, obj, request, url_args, url_kwargs):
interface_id = request.GET.get('interface')
if interface_id:
try:
obj.interface = Interface.objects.get(pk=interface_id)
except (ValueError, Interface.DoesNotExist):
pass
return obj
class IPAddressEditView(IPAddressCreateView):
permission_required = 'ipam.change_ipaddress'
@@ -664,7 +701,7 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
permission_required = 'ipam.add_ipaddress'
pattern_form = forms.IPAddressPatternForm
form = forms.IPAddressBulkCreateForm
model_form = forms.IPAddressBulkAddForm
pattern_target = 'address'
template_name = 'ipam/ipaddress_bulk_add.html'
@@ -681,7 +718,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_ipaddress'
cls = IPAddress
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filter = filters.IPAddressFilter
table = tables.IPAddressTable
form = forms.IPAddressBulkEditForm
@@ -691,7 +728,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_ipaddress'
cls = IPAddress
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filter = filters.IPAddressFilter
table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list'
@@ -712,7 +749,7 @@ class VLANGroupListView(ObjectListView):
class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vlangroup'
model = VLANGroup
form_class = forms.VLANGroupForm
model_form = forms.VLANGroupForm
def get_return_url(self, request, obj):
return reverse('ipam:vlangroup_list')
@@ -722,6 +759,13 @@ class VLANGroupEditView(VLANGroupCreateView):
permission_required = 'ipam.change_vlangroup'
class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlangroup'
model_form = forms.VLANGroupCSVForm
table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list'
class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_vlangroup'
cls = VLANGroup
@@ -763,7 +807,7 @@ class VLANView(View):
class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_vlan'
model = VLAN
form_class = forms.VLANForm
model_form = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
default_return_url = 'ipam:vlan_list'
@@ -811,16 +855,18 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_service'
model = Service
form_class = forms.ServiceForm
model_form = forms.ServiceForm
template_name = 'ipam/service_edit.html'
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
elif 'virtualmachine' in url_kwargs:
obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine'])
return obj
def get_return_url(self, request, obj):
return obj.device.get_absolute_url()
return obj.parent.get_absolute_url()
class ServiceEditView(ServiceCreateView):

147
netbox/netbox/api.py Normal file
View File

@@ -0,0 +1,147 @@
from __future__ import unicode_literals
from rest_framework import authentication, exceptions
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.utils import formatting
from users.models import Token
#
# Renderers
#
class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
"""
Override the built-in BrowsableAPIRenderer to disable HTML forms.
"""
def show_form_for_method(self, *args, **kwargs):
return False
#
# Authentication
#
class TokenAuthentication(authentication.TokenAuthentication):
"""
A custom authentication scheme which enforces Token expiration times.
"""
model = Token
def authenticate_credentials(self, key):
model = self.get_model()
try:
token = model.objects.select_related('user').get(key=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed("Invalid token")
# Enforce the Token's expiration time, if one has been set.
if token.is_expired:
raise exceptions.AuthenticationFailed("Token expired")
if not token.user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
return token.user, token
class TokenPermissions(DjangoModelPermissions):
"""
Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
for unsafe requests (POST/PUT/PATCH/DELETE).
"""
def __init__(self):
# LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
from django.conf import settings
self.authenticated_users_only = settings.LOGIN_REQUIRED
super(TokenPermissions, self).__init__()
def has_permission(self, request, view):
# If token authentication is in use, verify that the token allows write operations (for unsafe methods).
if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
if not request.auth.write_enabled:
return False
return super(TokenPermissions, self).has_permission(request, view)
#
# Pagination
#
class OptionalLimitOffsetPagination(LimitOffsetPagination):
"""
Override the stock paginator to allow setting limit=0 to disable pagination for a request. This returns all objects
matching a query, but retains the same format as a paginated request. The limit can only be disabled if
MAX_PAGE_SIZE has been set to 0 or None.
"""
def paginate_queryset(self, queryset, request, view=None):
try:
self.count = queryset.count()
except (AttributeError, TypeError):
self.count = len(queryset)
self.limit = self.get_limit(request)
self.offset = self.get_offset(request)
self.request = request
if self.limit and self.count > self.limit and self.template is not None:
self.display_page_controls = True
if self.count == 0 or self.offset > self.count:
return list()
if self.limit:
return list(queryset[self.offset:self.offset + self.limit])
else:
return list(queryset[self.offset:])
def get_limit(self, request):
from django.conf import settings
if self.limit_query_param:
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
if settings.MAX_PAGE_SIZE:
if limit == 0:
return settings.MAX_PAGE_SIZE
else:
return min(limit, settings.MAX_PAGE_SIZE)
return limit
except (KeyError, ValueError):
pass
return self.default_limit
#
# Miscellaneous
#
def get_view_name(view_cls, suffix=None):
"""
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
"""
if hasattr(view_cls, 'queryset'):
# Determine the model name from the queryset.
name = view_cls.queryset.model._meta.verbose_name
name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
else:
# Replicate DRF's built-in behavior.
name = view_cls.__name__
name = formatting.remove_trailing_string(name, 'View')
name = formatting.remove_trailing_string(name, 'ViewSet')
name = formatting.camelcase_to_spaces(name)
if suffix:
name += ' ' + suffix
return name

View File

@@ -1,79 +0,0 @@
import os
#########################
# #
# Required settings #
# #
#########################
# This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write
# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name.
#
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(' ')
# PostgreSQL database configuration.
DATABASE = {
'NAME': os.environ.get('DB_NAME', 'netbox'), # Database name
'USER': os.environ.get('DB_USER', ''), # PostgreSQL username
'PASSWORD': os.environ.get('DB_PASSWORD', ''), # PostgreSQL password
'HOST': os.environ.get('DB_HOST', 'localhost'), # Database server
'PORT': os.environ.get('DB_PORT', ''), # Database port (leave blank for default)
}
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
# symbols. NetBox will not run without this defined. For more information, see
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
SECRET_KEY = os.environ.get('SECRET_KEY', '')
#########################
# #
# Optional settings #
# #
#########################
# Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of
# application errors (assuming correct email settings are provided).
ADMINS = [
# ['John Doe', 'jdoe@example.com'],
]
# Email settings
EMAIL = {
'SERVER': os.environ.get('EMAIL_SERVER', 'localhost'),
'PORT': os.environ.get('EMAIL_PORT', 25),
'USERNAME': os.environ.get('EMAIL_USERNAME', ''),
'PASSWORD': os.environ.get('EMAIL_PASSWORD', ''),
'TIMEOUT': os.environ.get('EMAIL_TIMEOUT', 10), # seconds
'FROM_EMAIL': os.environ.get('EMAIL_FROM', ''),
}
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
LOGIN_REQUIRED = os.environ.get('LOGIN_REQUIRED', False)
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
# BASE_PATH = 'netbox/'
BASE_PATH = os.environ.get('BASE_PATH', '')
# Setting this to True will display a "maintenance mode" banner at the top of every page.
MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', False)
# Credentials that NetBox will use to access live devices.
NAPALM_USERNAME = os.environ.get('NAPALM_USERNAME', '')
NAPALM_PASSWORD = os.environ.get('NAPALM_PASSWORD', '')
# Determine how many objects to display per page within a list. (Default: 50)
PAGINATE_COUNT = os.environ.get('PAGINATE_COUNT', 50)
# Time zone (default: UTC)
TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC')
# Date/time formatting. See the following link for supported formats:
# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
DATE_FORMAT = os.environ.get('DATE_FORMAT', 'N j, Y')
SHORT_DATE_FORMAT = os.environ.get('SHORT_DATE_FORMAT', 'Y-m-d')
TIME_FORMAT = os.environ.get('TIME_FORMAT', 'g:i a')
SHORT_TIME_FORMAT = os.environ.get('SHORT_TIME_FORMAT', 'H:i:s')
DATETIME_FORMAT = os.environ.get('DATETIME_FORMAT', 'N j, Y g:i a')
SHORT_DATETIME_FORMAT = os.environ.get('SHORT_DATETIME_FORMAT', 'Y-m-d H:i')

View File

@@ -38,11 +38,14 @@ ADMINS = [
# ['John Doe', 'jdoe@example.com'],
]
# Optionally display a persistent banner at the top and/or bottom of every page. To display the same content in both
# banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
BANNER_TOP = ''
BANNER_BOTTOM = ''
# Text to include on the login page above the login form. HTML is allowed.
BANNER_LOGIN = ''
# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set:
# BASE_PATH = 'netbox/'
BASE_PATH = ''
@@ -93,6 +96,10 @@ MAINTENANCE_MODE = False
# all objects by specifying "?limit=0".
MAX_PAGE_SIZE = 1000
# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that
# the default value of this setting is derived from the installed location.
# MEDIA_ROOT = '/opt/netbox/netbox/media'
# Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM.
NAPALM_USERNAME = ''
NAPALM_PASSWORD = ''
@@ -111,6 +118,10 @@ PAGINATE_COUNT = 50
# prefer IPv4 instead.
PREFER_IPV4 = False
# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
# Time zone (default: UTC)
TIME_ZONE = 'UTC'

View File

@@ -30,12 +30,16 @@ OBJ_TYPE_CHOICES = (
('Tenancy', (
('tenant', 'Tenants'),
)),
('Virtualization', (
('cluster', 'Clusters'),
('virtualmachine', 'Virtual machines'),
)),
)
class SearchForm(BootstrapMixin, forms.Form):
q = forms.CharField(
label='Query', widget=forms.TextInput(attrs={'style': 'width: 350px'})
label='Search', widget=forms.TextInput(attrs={'style': 'width: 350px'})
)
obj_type = forms.ChoiceField(
choices=OBJ_TYPE_CHOICES, required=False, label='Type'

View File

@@ -13,7 +13,9 @@ except ImportError:
)
VERSION = '2.1.3'
VERSION = '2.2.2'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Import required configuration parameters
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
@@ -27,8 +29,9 @@ for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
# Import optional configuration parameters
ADMINS = getattr(configuration, 'ADMINS', [])
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
@@ -44,14 +47,14 @@ LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
NETBOX_USERNAME = getattr(configuration, 'NETBOX_USERNAME', '') # Deprecated
NETBOX_PASSWORD = getattr(configuration, 'NETBOX_PASSWORD', '') # Deprecated
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
@@ -60,19 +63,6 @@ TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# Check for deprecated configuration parameters
config_logger = logging.getLogger('configuration')
config_logger.addHandler(logging.StreamHandler())
config_logger.setLevel(logging.WARNING)
if NETBOX_USERNAME:
config_logger.warning('NETBOX_USERNAME is deprecated and will be removed in v2.2. Please use NAPALM_USERNAME instead.')
if not NAPALM_USERNAME:
NAPALM_USERNAME = NETBOX_USERNAME
if NETBOX_PASSWORD:
config_logger.warning('NETBOX_PASSWORD is deprecated and will be removed in v2.2. Please use NAPALM_PASSWORD instead.')
if not NAPALM_PASSWORD:
NAPALM_PASSWORD = NETBOX_PASSWORD
# Attempt to import LDAP configuration if it has been defined
LDAP_IGNORE_CERT_ERRORS = False
try:
@@ -104,8 +94,6 @@ if LDAP_CONFIGURED:
"netbox/ldap_config.py to disable LDAP."
)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Database
configuration.DATABASE.update({'ENGINE': 'django.db.backends.postgresql'})
DATABASES = {
@@ -145,6 +133,7 @@ INSTALLED_APPS = (
'tenancy',
'users',
'utilities',
'virtualization',
)
# Middleware
@@ -201,7 +190,6 @@ STATICFILES_DIRS = (
)
# Media
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/{}media/'.format(BASE_PATH)
# Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.)
@@ -224,30 +212,26 @@ REST_FRAMEWORK = {
'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'utilities.api.TokenAuthentication',
'netbox.api.TokenAuthentication',
),
'DEFAULT_FILTER_BACKENDS': (
'rest_framework.filters.DjangoFilterBackend',
'django_filters.rest_framework.DjangoFilterBackend',
),
'DEFAULT_PAGINATION_CLASS': 'utilities.api.OptionalLimitOffsetPagination',
'DEFAULT_PAGINATION_CLASS': 'netbox.api.OptionalLimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': (
'utilities.api.TokenPermissions',
'netbox.api.TokenPermissions',
),
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
'netbox.api.FormlessBrowsableAPIRenderer',
),
'DEFAULT_VERSION': REST_FRAMEWORK_VERSION,
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning',
'PAGE_SIZE': PAGINATE_COUNT,
'VIEW_NAME_FUNCTION': 'utilities.api.get_view_name',
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
}
# Django debug toolbar
# Disable the templates panel by default due to a performance issue in Django 1.11; see
# https://github.com/jazzband/django-debug-toolbar/issues/910
DEBUG_TOOLBAR_CONFIG = {
'DISABLE_PANELS': [
'debug_toolbar.panels.redirects.RedirectsPanel',
'debug_toolbar.panels.templates.TemplatesPanel',
],
}
INTERNAL_IPS = (
'127.0.0.1',
'::1',

View File

@@ -32,6 +32,7 @@ _patterns = [
url(r'^secrets/', include('secrets.urls')),
url(r'^tenancy/', include('tenancy.urls')),
url(r'^user/', include('users.urls')),
url(r'^virtualization/', include('virtualization.urls')),
# API
url(r'^api/$', APIRootView.as_view(), name='api-root'),
@@ -41,6 +42,7 @@ _patterns = [
url(r'^api/ipam/', include('ipam.api.urls')),
url(r'^api/secrets/', include('secrets.api.urls')),
url(r'^api/tenancy/', include('tenancy.api.urls')),
url(r'^api/virtualization/', include('virtualization.api.urls')),
url(r'^api/docs/', swagger_view, name='api_docs'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware

View File

@@ -14,7 +14,7 @@ from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site
from dcim.tables import DeviceTable, DeviceTypeTable, RackTable, SiteTable
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable
from extras.models import TopologyMap, UserAction
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@@ -25,6 +25,9 @@ from secrets.tables import SecretTable
from tenancy.filters import TenantFilter
from tenancy.models import Tenant
from tenancy.tables import TenantTable
from virtualization.filters import ClusterFilter, VirtualMachineFilter
from virtualization.models import Cluster, VirtualMachine
from virtualization.tables import ClusterTable, VirtualMachineDetailTable
from .forms import SearchForm
@@ -64,10 +67,10 @@ SEARCH_TYPES = OrderedDict((
}),
('device', {
'queryset': Device.objects.select_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
),
'filter': DeviceFilter,
'table': DeviceTable,
'table': DeviceDetailTable,
'url': 'dcim:device_list',
}),
# IPAM
@@ -90,7 +93,7 @@ SEARCH_TYPES = OrderedDict((
'url': 'ipam:prefix_list',
}),
('ipaddress', {
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'),
'filter': IPAddressFilter,
'table': IPAddressTable,
'url': 'ipam:ipaddress_list',
@@ -115,6 +118,21 @@ SEARCH_TYPES = OrderedDict((
'table': TenantTable,
'url': 'tenancy:tenant_list',
}),
# Virtualization
('cluster', {
'queryset': Cluster.objects.all(),
'filter': ClusterFilter,
'table': ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'queryset': VirtualMachine.objects.select_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filter': VirtualMachineFilter,
'table': VirtualMachineDetailTable,
'url': 'virtualization:virtualmachine_list',
}),
))
@@ -150,6 +168,10 @@ class HomeView(View):
# Secrets
'secret_count': Secret.objects.count(),
# Virtualization
'cluster_count': Cluster.objects.count(),
'virtualmachine_count': VirtualMachine.objects.count(),
}
return render(request, self.template_name, {
@@ -216,14 +238,15 @@ class APIRootView(APIView):
def get(self, request, format=None):
return Response({
'circuits': reverse('circuits-api:api-root', request=request, format=format),
'dcim': reverse('dcim-api:api-root', request=request, format=format),
'extras': reverse('extras-api:api-root', request=request, format=format),
'ipam': reverse('ipam-api:api-root', request=request, format=format),
'secrets': reverse('secrets-api:api-root', request=request, format=format),
'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
})
return Response(OrderedDict((
('circuits', reverse('circuits-api:api-root', request=request, format=format)),
('dcim', reverse('dcim-api:api-root', request=request, format=format)),
('extras', reverse('extras-api:api-root', request=request, format=format)),
('ipam', reverse('ipam-api:api-root', request=request, format=format)),
('secrets', reverse('secrets-api:api-root', request=request, format=format)),
('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
)))
def handle_500(request):

View File

@@ -85,6 +85,19 @@ footer p {
li.subnav > a {
padding-left: 30px;
}
ul.dropdown-menu {
width: 250px;
}
ul.dropdown-menu div.buttons {
padding-right: 10px;
padding-top: 2px;
}
ul.dropdown-menu div.buttons a {
width: 26px;
}
ul.dropdown-menu > li > a {
clear: left;
}
/* Forms */
label {
@@ -329,13 +342,26 @@ li.occupied + li.available {
}
/* Devices */
table.component-list tr.ipaddress td {
background-color: #eeffff;
padding-bottom: 4px;
padding-top: 4px;
table.component-list td.subtable {
padding: 0;
padding-left: 16px;
}
table.component-list tr.ipaddress:hover td {
background-color: #e6f7f7;
table.component-list td.subtable td {
border: none;
padding-bottom: 6px;
padding-top: 6px;
}
/* Reports */
table.reports td.method {
font-family: monospace;
padding-left: 30px;
}
table.reports td.stats label {
display: inline-block;
line-height: 14px;
margin-bottom: 0;
min-width: 40px;
}
/* AJAX loader */

View File

@@ -77,7 +77,9 @@ $(document).ready(function() {
// Wipe out any existing options within the child field and create a default option
child_field.empty();
child_field.append($("<option></option>").attr("value", "").text("---------"));
if (!child_field.attr('multiple')) {
child_field.append($("<option></option>").attr("value", "").text("---------"));
}
if ($(this).val() || $(this).attr('nullable') == 'true') {
var api_url = child_field.attr('api-url') + '&limit=1000';

View File

@@ -18,7 +18,7 @@ $(document).ready(function() {
$('form').submit(function(event) {
$(this).find('.requires-session-key').each(function() {
if (this.value && document.cookie.indexOf('session_key') == -1) {
console.log('Field ' + this.value + ' requires a session key');
console.log('Field ' + this.name + ' requires a session key');
$('#privkey_modal').modal('show');
event.preventDefault();
return false;
@@ -43,7 +43,7 @@ $(document).ready(function() {
success: function (response, status) {
if (response.plaintext) {
console.log("Secret retrieved successfully");
$('#secret_' + secret_id).html(response.plaintext);
$('#secret_' + secret_id).text(response.plaintext);
$('button.unlock-secret[secret-id=' + secret_id + ']').hide();
$('button.lock-secret[secret-id=' + secret_id + ']').show();
} else {

View File

View File

@@ -16,6 +16,9 @@ class SecretsRootView(routers.APIRootView):
router = routers.DefaultRouter()
router.APIRootView = SecretsRootView
# Field choices
router.register(r'_choices', views.SecretsFieldChoicesViewSet, base_name='field-choice')
# Secrets
router.register(r'secret-roles', views.SecretRoleViewSet)
router.register(r'secrets', views.SecretViewSet)

View File

@@ -12,7 +12,7 @@ from django.http import HttpResponseBadRequest
from secrets import filters
from secrets.exceptions import InvalidKey
from secrets.models import Secret, SecretRole, SessionKey, UserKey
from utilities.api import WritableSerializerMixin
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
from . import serializers
@@ -22,6 +22,14 @@ ERR_PRIVKEY_MISSING = "Private key was not provided."
ERR_PRIVKEY_INVALID = "Invalid private key."
#
# Field choices
#
class SecretsFieldChoicesViewSet(FieldChoicesViewSet):
fields = ()
#
# Secret Roles
#

View File

@@ -43,6 +43,17 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm):
fields = ['name', 'slug', 'users', 'groups']
class SecretRoleCSVForm(forms.ModelForm):
slug = SlugField()
class Meta:
model = SecretRole
fields = ['name', 'slug']
help_texts = {
'name': 'Name of secret role',
}
#
# Secrets
#

View File

@@ -1,8 +1,9 @@
from __future__ import unicode_literals
import os
from Crypto.Cipher import AES, PKCS1_OAEP, XOR
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
from Crypto.Util import strxor
from django.conf import settings
from django.contrib.auth.hashers import make_password, check_password
@@ -16,6 +17,7 @@ from dcim.models import Device
from utilities.models import CreatedUpdatedModel
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
from .querysets import UserKeyQuerySet
def generate_random_key(bits=256):
@@ -45,24 +47,6 @@ def decrypt_master_key(master_key_cipher, private_key):
return cipher.decrypt(master_key_cipher)
def xor_keys(key_a, key_b):
"""
Return the binary XOR of two given keys.
"""
xor = XOR.new(key_a)
return xor.encrypt(key_b)
class UserKeyQuerySet(models.QuerySet):
def active(self):
return self.filter(master_key_cipher__isnull=False)
def delete(self):
# Disable bulk deletion to avoid accidentally wiping out all copies of the master key.
raise Exception("Bulk deletion has been disabled.")
@python_2_unicode_compatible
class UserKey(CreatedUpdatedModel):
"""
@@ -98,7 +82,7 @@ class UserKey(CreatedUpdatedModel):
# Validate the public key format
try:
pubkey = RSA.importKey(self.public_key)
pubkey = RSA.import_key(self.public_key)
except ValueError:
raise ValidationError({
'public_key': "Invalid RSA key format."
@@ -108,7 +92,7 @@ class UserKey(CreatedUpdatedModel):
"uploading a valid RSA public key in PEM format (no SSH/PGP).")
# Validate the public key length
pubkey_length = pubkey.size() + 1 # key.size() returns 1 less than the key modulus
pubkey_length = pubkey.size_in_bits()
if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE:
raise ValidationError({
'public_key': "Insufficient key length. Keys must be at least {} bits long.".format(
@@ -214,7 +198,7 @@ class SessionKey(models.Model):
self.hash = make_password(self.key)
# Encrypt master key using the session key
self.cipher = xor_keys(self.key, master_key)
self.cipher = strxor.strxor(self.key, master_key)
super(SessionKey, self).save(*args, **kwargs)
@@ -225,14 +209,14 @@ class SessionKey(models.Model):
raise InvalidKey("Invalid session key")
# Decrypt master key using provided session key
master_key = xor_keys(session_key, bytes(self.cipher))
master_key = strxor.strxor(session_key, bytes(self.cipher))
return master_key
def get_session_key(self, master_key):
# Recover session key using the master key
session_key = xor_keys(master_key, bytes(self.cipher))
session_key = strxor.strxor(master_key, bytes(self.cipher))
# Validate the recovered session key
if not check_password(session_key, self.hash):

View File

@@ -0,0 +1,13 @@
from __future__ import unicode_literals
from django.db.models import QuerySet
class UserKeyQuerySet(QuerySet):
def active(self):
return self.filter(master_key_cipher__isnull=False)
def delete(self):
# Disable bulk deletion to avoid accidentally wiping out all copies of the master key.
raise Exception("Bulk deletion has been disabled.")

View File

@@ -11,6 +11,7 @@ urlpatterns = [
# Secret roles
url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'),
url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
url(r'^secret-roles/(?P<slug>[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'),

View File

@@ -42,7 +42,7 @@ class SecretRoleListView(ObjectListView):
class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'secrets.add_secretrole'
model = SecretRole
form_class = forms.SecretRoleForm
model_form = forms.SecretRoleForm
def get_return_url(self, request, obj):
return reverse('secrets:secretrole_list')
@@ -52,6 +52,13 @@ class SecretRoleEditView(SecretRoleCreateView):
permission_required = 'secrets.change_secretrole'
class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'secrets.add_secretrole'
model_form = forms.SecretRoleCSVForm
table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list'
class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'secrets.delete_secretrole'
cls = SecretRole
@@ -196,7 +203,9 @@ class SecretBulkImportView(BulkImportView):
permission_required = 'ipam.add_vlan'
model_form = forms.SecretCSVForm
table = tables.SecretTable
template_name = 'secrets/secret_import.html'
default_return_url = 'secrets:secret_list'
widget_attrs = {'class': 'requires-session-key'}
master_key = None

View File

@@ -3,9 +3,10 @@
<html lang="en">
<head>
<title>Server Error</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<title>Server Error</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
<meta charset="UTF-8">
</head>
<body>

View File

@@ -3,278 +3,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% block title %}Home{% endblock %} - NetBox</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<title>{% block title %}Home{% endblock %} - NetBox</title>
<link rel="stylesheet" href="{% static 'bootstrap-3.3.7-dist/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'font-awesome-4.7.0/css/font-awesome.min.css' %}">
<link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
<link rel="stylesheet" href="{% static 'css/base.css' %}?v{{ settings.VERSION }}">
<link rel="stylesheet" href="{% static 'css/base.css' %}?v{{ settings.VERSION }}">
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
<meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
</head>
<body>
<nav class="navbar navbar-default navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{% url 'home' %}">
<img src="{% static 'img/netbox_logo.png' %}" />
</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
<ul class="nav navbar-nav">
<li class="dropdown{% if request.path|contains:'/dcim/sites/,/dcim/regions/,/tenancy/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Organization <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'dcim:site_list' %}"><strong>Sites</strong></a></li>
{% if perms.dcim.add_site %}
<li class="subnav"><a href="{% url 'dcim:site_add' %}"><i class="fa fa-plus"></i> Add a Site</a></li>
<li class="subnav"><a href="{% url 'dcim:site_import' %}"><i class="fa fa-download"></i> Import Sites</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:region_list' %}"><strong>Regions</strong></a></li>
{% if perms.dcim.add_region %}
<li class="subnav"><a href="{% url 'dcim:region_add' %}"><i class="fa fa-plus"></i> Add a Region</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'tenancy:tenant_list' %}"><strong>Tenants</strong></a></li>
{% if perms.tenancy.add_tenant %}
<li class="subnav"><a href="{% url 'tenancy:tenant_add' %}"><i class="fa fa-plus"></i> Add a Tenant</a></li>
<li class="subnav"><a href="{% url 'tenancy:tenant_import' %}"><i class="fa fa-download"></i> Import Tenants</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'tenancy:tenantgroup_list' %}"><strong>Tenant Groups</strong></a></li>
{% if perms.tenancy.add_tenantgroup %}
<li class="subnav"><a href="{% url 'tenancy:tenantgroup_add' %}"><i class="fa fa-plus"></i> Add a Tenant Group</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/rack' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Racks <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'dcim:rack_list' %}"><strong>Racks</strong></a></li>
{% if perms.dcim.add_rack %}
<li class="subnav"><a href="{% url 'dcim:rack_add' %}"><i class="fa fa-plus"></i> Add a Rack</a></li>
<li class="subnav"><a href="{% url 'dcim:rack_import' %}"><i class="fa fa-download"></i> Import Racks</a></li>
{% endif %}
<li><a href="{% url 'dcim:rack_elevation_list' %}"><strong>Rack Elevations</strong></a></li>
<li class="divider"></li>
<li><a href="{% url 'dcim:rackgroup_list' %}"><strong>Rack Groups</strong></a></li>
{% if perms.dcim.add_rackgroup %}
<li class="subnav"><a href="{% url 'dcim:rackgroup_add' %}"><i class="fa fa-plus"></i> Add a Rack Group</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:rackrole_list' %}"><strong>Rack Roles</strong></a></li>
{% if perms.dcim.add_rackrole %}
<li class="subnav"><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus"></i> Add a Rack Role</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:rackreservation_list' %}"><strong>Rack Reservations</strong></a></li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'dcim:device_list' %}"><strong>Devices</strong></a></li>
{% if perms.dcim.add_device %}
<li class="subnav"><a href="{% url 'dcim:device_add' %}"><i class="fa fa-plus"></i> Add a Device</a></li>
<li class="subnav"><a href="{% url 'dcim:device_import' %}"><i class="fa fa-download"></i> Import Devices</a></li>
{% endif %}
{% if perms.ipam.add_device or perms.ipam.add_devicetype %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'dcim:devicetype_list' %}"><strong>Device Types</strong></a></li>
{% if perms.dcim.add_devicetype %}
<li class="subnav"><a href="{% url 'dcim:devicetype_add' %}"><i class="fa fa-plus"></i> Add a Device Type</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:devicerole_list' %}"><strong>Device Roles</strong></a></li>
{% if perms.dcim.add_devicerole %}
<li class="subnav"><a href="{% url 'dcim:devicerole_add' %}"><i class="fa fa-plus"></i> Add a Device Role</a></li>
{% endif %}
{% if perms.dcim.add_devicerole or perms.dcim.add_manufacturer %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'dcim:manufacturer_list' %}"><strong>Manufacturers</strong></a></li>
{% if perms.dcim.add_manufacturer %}
<li class="subnav"><a href="{% url 'dcim:manufacturer_add' %}"><i class="fa fa-plus"></i> Add a Manufacturer</a></li>
{% endif %}
{% if perms.dcim.add_manufacturer or perms.dcim.add_platform %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'dcim:platform_list' %}"><strong>Platforms</strong></a></li>
{% if perms.dcim.add_platform %}
<li class="subnav"><a href="{% url 'dcim:platform_add' %}"><i class="fa fa-plus"></i> Add a Platform</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/dcim/console-connections/,/dcim/power-connections/,/dcim/interface-connections/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Connections <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'dcim:console_connections_list' %}"><strong>Console Connections</strong></a></li>
{% if perms.dcim.change_consoleport %}
<li class="subnav"><a href="{% url 'dcim:console_connections_import' %}"><i class="fa fa-download"></i> Import Console Connections</a></li>
{% endif %}
{% if perms.ipam.change_consoleport or perms.ipam.change_powerport %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'dcim:power_connections_list' %}"><strong>Power Connections</strong></a></li>
{% if perms.dcim.change_powerport %}
<li class="subnav"><a href="{% url 'dcim:power_connections_import' %}"><i class="fa fa-download"></i> Import Power Connections</a></li>
{% endif %}
{% if perms.ipam.change_powerport or perms.ipam.add_interfaceconnection %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'dcim:interface_connections_list' %}"><strong>Interface Connections</strong></a></li>
{% if perms.dcim.add_interfaceconnection %}
<li class="subnav"><a href="{% url 'dcim:interface_connections_import' %}"><i class="fa fa-download"></i> Import Interface Connections</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/ipam/' and not request.path|contains:'/ipam/vlan' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Space <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'ipam:ipaddress_list' %}"><strong>IP Addresses</strong></a></li>
{% if perms.ipam.add_ipaddress %}
<li class="subnav"><a href="{% url 'ipam:ipaddress_add' %}"><i class="fa fa-plus"></i> Add an IP</a></li>
<li class="subnav"><a href="{% url 'ipam:ipaddress_import' %}"><i class="fa fa-download"></i> Import IPs</a></li>
{% endif %}
{% if perms.ipam.add_ipaddress or perms.ipam.add_prefix %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'ipam:prefix_list' %}"><strong>Prefixes</strong></a></li>
{% if perms.ipam.add_prefix %}
<li class="subnav"><a href="{% url 'ipam:prefix_add' %}"><i class="fa fa-plus"></i> Add a Prefix</a></li>
<li class="subnav"><a href="{% url 'ipam:prefix_import' %}"><i class="fa fa-download"></i> Import Prefixes</a></li>
{% endif %}
{% if perms.ipam.add_prefix or perms.ipam.add_aggregate %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'ipam:aggregate_list' %}"><strong>Aggregates</strong></a></li>
{% if perms.ipam.add_aggregate %}
<li class="subnav"><a href="{% url 'ipam:aggregate_add' %}"><i class="fa fa-plus"></i> Add an Aggregate</a></li>
<li class="subnav"><a href="{% url 'ipam:aggregate_import' %}"><i class="fa fa-download"></i> Import Aggregates</a></li>
{% endif %}
{% if perms.ipam.add_aggregate or perms.ipam.add_vrf %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'ipam:vrf_list' %}"><strong>VRFs</strong></a></li>
{% if perms.ipam.add_vrf %}
<li class="subnav"><a href="{% url 'ipam:vrf_add' %}"><i class="fa fa-plus"></i> Add a VRF</a></li>
<li class="subnav"><a href="{% url 'ipam:vrf_import' %}"><i class="fa fa-download"></i> Import VRFs</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'ipam:rir_list' %}"><strong>RIRs</strong></a></li>
{% if perms.ipam.add_rir %}
<li class="subnav"><a href="{% url 'ipam:rir_add' %}"><i class="fa fa-plus"></i> Add a RIR</a></li>
{% endif %}
{% if perms.ipam.add_rir or perms.ipam.add_role %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'ipam:role_list' %}"><strong>Prefix Roles</strong></a></li>
{% if perms.ipam.add_role %}
<li class="subnav"><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus"></i> Add a Role</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/ipam/vlan' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'ipam:vlan_list' %}"><strong>VLANs</strong></a></li>
{% if perms.ipam.add_vlan %}
<li class="subnav"><a href="{% url 'ipam:vlan_add' %}"><i class="fa fa-plus"></i> Add a VLAN</a></li>
<li class="subnav"><a href="{% url 'ipam:vlan_import' %}"><i class="fa fa-download"></i> Import VLANs</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'ipam:vlangroup_list' %}"><strong>VLAN Groups</strong></a></li>
{% if perms.ipam.add_vlangroup %}
<li class="subnav"><a href="{% url 'ipam:vlangroup_add' %}"><i class="fa fa-plus"></i> Add a VLAN Group</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'ipam:role_list' %}"><strong>VLAN Roles</strong></a></li>
{% if perms.ipam.add_role %}
<li class="subnav"><a href="{% url 'ipam:role_add' %}"><i class="fa fa-plus"></i> Add a Role</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/circuits/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'circuits:provider_list' %}"><strong>Providers</strong></a></li>
{% if perms.circuits.add_provider %}
<li class="subnav"><a href="{% url 'circuits:provider_add' %}"><i class="fa fa-plus"></i> Add a Provider</a></li>
<li class="subnav"><a href="{% url 'circuits:provider_import' %}"><i class="fa fa-download"></i> Import Providers</a></li>
{% endif %}
{% if perms.circuits.add_circuit or perms.circuits.add_provider %}
<li class="divider"></li>
{% endif %}
<li><a href="{% url 'circuits:circuit_list' %}"><strong>Circuits</strong></a></li>
{% if perms.circuits.add_circuit %}
<li class="subnav"><a href="{% url 'circuits:circuit_add' %}"><i class="fa fa-plus"></i> Add a Circuit</a></li>
<li class="subnav"><a href="{% url 'circuits:circuit_import' %}"><i class="fa fa-download"></i> Import Circuits</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'circuits:circuittype_list' %}"><strong>Circuit Types</strong></a></li>
{% if perms.circuits.add_circuittype %}
<li class="subnav"><a href="{% url 'circuits:circuittype_add' %}"><i class="fa fa-plus"></i> Add a Circuit Type</a></li>
{% endif %}
</ul>
</li>
{% if request.user.is_authenticated %}
<li class="dropdown{% if request.path|contains:'/secrets/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Secrets <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="{% url 'secrets:secret_list' %}"><strong>Secrets</strong></a></li>
{% if perms.secrets.add_secret %}
<li class="subnav"><a href="{% url 'secrets:secret_import' %}"><i class="fa fa-download"></i> Import Secrets</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'secrets:secretrole_list' %}"><strong>Secret Roles</strong></a></li>
{% if perms.secrets.add_secretrole %}
<li class="subnav"><a href="{% url 'secrets:secretrole_add' %}"><i class="fa fa-plus"></i> Add a Secret Role</a></li>
{% endif %}
</ul>
</li>
{% endif %}
</ul>
{% endif %}
<ul class="nav navbar-nav navbar-right">
{% if request.user.is_authenticated %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" title="{{ request.user }}" role="button" aria-haspopup="true" aria-expanded="false">
{{ request.user|truncatechars:"30" }} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li><a href="{% url 'user:profile' %}"><i class="fa fa-user"></i> Profile</a></li>
{% if request.user.is_staff %}
<li><a href="{% url 'admin:index' %}"><i class="fa fa-cogs"></i> Admin</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'logout' %}"><i class="fa fa-sign-out"></i> Log out</a></li>
</ul>
</li>
{% else %}
<li><a href="{% url 'login' %}?next={{ request.path }}"><i class="fa fa-sign-in"></i> Log in</a></li>
{% endif %}
</ul>
<form action="{% url 'search' %}" method="get" class="navbar-form navbar-right" id="navbar_search" role="search">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search">
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<i class="fa fa-search"></i>
</button>
</span>
</div>
</form>
</div>
</div>
</nav>
{% include 'inc/nav_menu.html' %}
<div class="container wrapper">
{% if settings.BANNER_TOP %}
<div class="alert alert-info text-center" role="alert">

View File

@@ -88,7 +88,7 @@
<td>Commit Rate</td>
<td>
{% if circuit.commit_rate %}
{{ circuit.commit_rate_human }}
{{ circuit.commit_rate|humanize_speed }}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}

View File

@@ -9,7 +9,16 @@
{% render_field form.cid %}
{% render_field form.type %}
{% render_field form.install_date %}
{% render_field form.commit_rate %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_commit_rate">{{ form.commit_rate.label }}</label>
<div class="col-md-9">
<div class="input-group">
{{ form.commit_rate }}
{% include 'circuits/inc/speed_widget.html' with target_field='commit_rate' %}
</div>
<span class="help-block">{{ form.commit_rate.help_text }}</span>
</div>
</div>
{% render_field form.description %}
</div>
</div>
@@ -35,3 +44,12 @@
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$("a.set_speed").click(function(e) {
e.preventDefault();
$("#id_" + $(this).attr("target")).val($(this).attr("data"));
});
</script>
{% endblock %}

View File

@@ -49,8 +49,26 @@
<div class="panel panel-default">
<div class="panel-heading"><strong>Termination Details</strong></div>
<div class="panel-body">
{% render_field form.port_speed %}
{% render_field form.upstream_speed %}
<div class="form-group">
<label class="col-md-3 control-label required" for="id_port_speed">{{ form.port_speed.label }}</label>
<div class="col-md-9">
<div class="input-group">
{{ form.port_speed }}
{% include 'circuits/inc/speed_widget.html' with target_field='port_speed' %}
</div>
<span class="help-block">{{ form.port_speed.help_text }}</span>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label" for="id_upstream_speed">{{ form.upstream_speed.label }}</label>
<div class="col-md-9">
<div class="input-group">
{{ form.upstream_speed }}
{% include 'circuits/inc/speed_widget.html' with target_field='upstream_speed' %}
</div>
<span class="help-block">{{ form.upstream_speed.help_text }}</span>
</div>
</div>
{% render_field form.xconnect_id %}
{% render_field form.pp_info %}
</div>
@@ -72,4 +90,10 @@
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}?v{{ settings.VERSION }}"></script>
<script type="text/javascript">
$("a.set_speed").click(function(e) {
e.preventDefault();
$("#id_" + $(this).attr("target")).val($(this).attr("data"));
});
</script>
{% endblock %}

View File

@@ -1,3 +1,5 @@
{% load helpers %}
<div class="panel panel-default">
<div class="panel-heading">
<div class="pull-right">
@@ -49,10 +51,10 @@
<td>Speed</td>
<td>
{% if termination.upstream_speed %}
<i class="fa fa-arrow-down" title="Downstream"></i> {{ termination.port_speed_human }} &nbsp;
<i class="fa fa-arrow-up" title="Upstream"></i> {{ termination.upstream_speed_human }}
<i class="fa fa-arrow-down" title="Downstream"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="fa fa-arrow-up" title="Upstream"></i> {{ termination.upstream_speed|humanize_speed }}
{% else %}
{{ termination.port_speed_human }}
{{ termination.port_speed|humanize_speed }}
{% endif %}
</td>
</tr>

View File

@@ -0,0 +1,17 @@
<span class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="#" target="{{ target_field }}" data="10000" class="set_speed">10 Mbps</a></li>
<li><a href="#" target="{{ target_field }}" data="100000" class="set_speed">100 Mbps</a></li>
<li><a href="#" target="{{ target_field }}" data="1000000" class="set_speed">1 Gbps</a></li>
<li><a href="#" target="{{ target_field }}" data="10000000" class="set_speed">10 Gbps</a></li>
<li><a href="#" target="{{ target_field }}" data="25000000" class="set_speed">25 Gbps</a></li>
<li><a href="#" target="{{ target_field }}" data="40000000" class="set_speed">40 Gbps</a></li>
<li><a href="#" target="{{ target_field }}" data="100000000" class="set_speed">100 Gbps</a></li>
<li class="divider"></li>
<li><a href="#" target="{{ target_field }}" data="1544" class="set_speed">T1 (1.544 Mbps)</a></li>
<li><a href="#" target="{{ target_field }}" data="2048" class="set_speed">E1 (2.048 Mbps)</a></li>
</ul>
</span>

View File

@@ -155,6 +155,18 @@
{% endif %}
</td>
</tr>
{% if device.cluster %}
<tr>
<td>Cluster</td>
<td>
{% if device.cluster.group %}
<a href="{{ device.cluster.group.get_absolute_url }}">{{ device.cluster.group }}</a>
<i class="fa fa-angle-right"></i>
{% endif %}
<a href="{{ device.cluster.get_absolute_url }}">{{ device.cluster }}</a>
</td>
</tr>
{% endif %}
</table>
</div>
{% with device.get_custom_fields as custom_fields %}
@@ -196,7 +208,7 @@
{% if services %}
<table class="table table-hover panel-body">
{% for service in services %}
{% include 'dcim/inc/service.html' %}
{% include 'ipam/inc/service.html' %}
{% endfor %}
</table>
{% else %}
@@ -206,7 +218,7 @@
{% endif %}
{% if perms.ipam.add_service %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
<a href="{% url 'dcim:device_service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
</a>
</div>

View File

@@ -53,7 +53,7 @@ $(document).ready(function() {
success: function(json) {
$.each(json['get_lldp_neighbors'], function(iface, neighbors) {
var neighbor = neighbors[0];
var row = $('#' + iface.replace(/(\/)/g, "\\$1"));
var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1"));
var configured_device = row.children('td.configured_device').attr('data');
var configured_interface = row.children('td.configured_interface').attr('data');
// Add LLDP neighbors to table
@@ -62,7 +62,7 @@ $(document).ready(function() {
// Apply colors to rows
if (!configured_device && neighbor['hostname']) {
row.addClass('info');
} else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port']) {
} else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port'].split(".")[0]) {
row.addClass('success');
} else {
row.addClass('danger');

View File

@@ -80,13 +80,19 @@ $(document).ready(function() {
$('#model').html(json['get_facts']['model']);
$('#serial_number').html(json['get_facts']['serial_number']);
$('#os_version').html(json['get_facts']['os_version']);
$('#uptime').html(json['get_facts']['uptime']);
// Calculate uptime
var uptime = json['get_facts']['uptime'];
console.log(uptime);
var uptime_days = Math.floor(uptime / 86400);
var uptime_hours = Math.floor(uptime % 86400 / 3600);
var uptime_minutes = Math.floor(uptime % 3600 / 60);
$('#uptime').html(uptime_days + "d " + uptime_hours + "h " + uptime_minutes + "m");
$.each(json['get_environment']['cpu'], function(name, obj) {
var row="<tr><td>" + name + "</td><td>" + obj['%usage'] + "%</td></tr>";
$("#cpu").after(row)
});
$('#memory').after("<tr><td>Used</td><td>" + json['get_environment']['memory']['used_ram'] + "MB</td></tr>");
$('#memory').after("<tr><td>Available</td><td>" + json['get_environment']['memory']['available_ram'] + "MB</td></tr>");
$('#memory').after("<tr><td>Used</td><td>" + json['get_environment']['memory']['used_ram'] + "</td></tr>");
$('#memory').after("<tr><td>Available</td><td>" + json['get_environment']['memory']['available_ram'] + "</td></tr>");
$.each(json['get_environment']['temperature'], function(name, obj) {
var style = "success";
if (obj['is_alert']) {

View File

@@ -8,7 +8,12 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add a device type
</a>
<a href="{% url 'dcim:devicetype_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import device types
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='devicetypes' %}
</div>
<h1>{% block title %}Device Types{% endblock %}</h1>
<div class="row">

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